Compare commits

...

29 Commits

Author SHA1 Message Date
nntrivi2001 fb65dbe795 chore: v2.8.82 — weekly database backups, no error reports from server Claude panes 2026-11-09 13:07:34 +07:00
nntrivi2001 0df2594f19 feat(ops): backups every N days; server-mode Claude panes send no error reports
Settings → Data → Database backup takes "How often" (every day, 2, 3 days,
every week, 2 weeks, 30 days): AGENTHUB_BACKUP_EVERY (1–30, default 1),
due once the newest backup in the folder is that many calendar days old.

Server mode: every claude pane gets DISABLE_ERROR_REPORTING=1 — a crash
report can carry a user's paths and Linux account — while telemetry and
feature flags stay on, since turning those off removes flag-gated features
(artifact comments, synced skills/plugins, advisor, MCP tasks). Local mode
is unchanged; the dashboard's own env setting wins.
2026-11-09 11:37:17 +07:00
nntrivi2001 477dd03d31 chore: v2.8.81 — laptops off by default, Server settings tab, no admin password again, pool board auto-saves 2026-11-09 11:20:45 +07:00
nntrivi2001 9e6f16400a feat(admin): no admin password again; the pool board saves itself
Only admins reach Administration and the server already checks the role
(ADMIN_WRITE_PREFIXES, EADMIN), so the Users page, account pool, pool
groups, member tmux restarts, quotas, laptop switch and device revoke no
longer ask the admin's own password (a stale currentPassword is ignored).
confirmPassword stays for an account changing its own password.
Destructive actions still confirm.

The pool board has no Save button: every drag, rename, move or delete is
saved ~600 ms after the last edit. The board is frozen while a save runs
and until the re-read lands, so an edit made meanwhile is never lost or
sent twice; a failed save says why and is sent again only on Retry or a
further edit. New pools start as "Pool <n>".
2026-11-09 11:16:35 +07:00
nntrivi2001 a31f314688 feat(admin): server-wide settings move to Administration → Server settings
In server mode every setting that acts on the whole server, not on the
signed-in user, is an admin's and now lives on a second Administration tab
(?tab=server, components/admin/ServerSettings.tsx) instead of Settings:
laptop connections with every enrolled machine, Tests on this server, Oh My
Pi login sharing, Artifacts Hub installs, AgentHub's git logins, scheduled
reports, start at login / tray / Quit / Uninstall, model pricing and Data.

- Settings (server mode) drops them; /settings/data and /settings/pricing
  redirect to /admin?tab=server#<page>; an admin gets a link under the nav;
  their search entries carry serverAdmin and leave Settings' search.
- A non-admin keeps Company hub (their own machines, who read their
  sessions) in Settings; an analyst sees Administration's users, no tabs.
- Local mode (no Administration) keeps everything in Settings.
- AllPricingSettings is the one Pricing page body both places render.
2026-11-09 10:56:24 +07:00
nntrivi2001 89f58a37e8 feat(privacy): laptops off by default; hub keeps no host name, IP or path; photo EXIF stripped
Remote clients (server/hub/):
- An admin switch (remote-policy.js, <data dir>/remote-hub.json, GET/PUT
  /api/remote/policy — PUT is admin + own password, audited) decides whether
  laptops may connect at all. OFF by default: device login, every
  /api/remote/device/* route and the gateway answer 403 EREMOTEOFF; the
  signed-in views keep working. Settings → AI tools → Company hub.
- devices.last_ip is never written; a client enrolls under the name the user
  gives, never its host name; a session registers its folder's last segment
  and the hub cuts an older client's full path the same way.
- Uploaded transcripts pass server/lib/path-privacy.js: the folders above
  the project and the home become ~ (also as Claude's -home-<account>-…
  folder names), the host name "device", the account in ls -l columns "user".
- A Company-account pane gets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1:
  measured with Claude Code 2.1.319, it otherwise sends telemetry with the
  device id straight from the laptop.
- remote-privacy.js cleans, once per hub, what older clients stored: device
  names and IPs, folders, the upload copy and the importer snapshot of each
  transcript, every text column of the session's rows, audit lines naming a
  device. Token rows are untouched. Dry run on a copy of the live data:
  2 devices, 24 sessions, 47 files, 29 rows; no path or host name left;
  10 313 lines still parse.

Uploads (client/src/lib/imageMetadata.ts):
- JPEG/PNG/WebP lose EXIF (GPS, camera, owner), XMP, IPTC, comments and
  text chunks before upload — lossless, a JPEG keeps only its orientation,
  and data a phone appends after the image is cut. Applied to File Explorer
  uploads (plain, resumable, batch), terminal/chat drops, the Changes
  panel's upload, workspace context files, avatars and Artifacts uploads.
2026-11-09 10:43:49 +07:00
nntrivi2001 f975066c62 chore: v2.8.80 — Shift+Enter new line, chat lists 2026-11-06 15:22:24 +07:00
nntrivi2001 c557c0ad2f fix(chat): nested lists, and numbered lists split by a blank line keep counting
MarkdownContent nests list items by indent and starts an ordered list at the
number its text gives, so a list broken by a blank line or a stray block no
longer restarts at 1.
2026-11-06 15:06:09 +07:00
nntrivi2001 d086a7f198 feat(terminal): Shift+Enter adds a new line in claude/codex prompts
xterm sends a plain CR for Shift+Enter, so the prompt was submitted. Send
Ctrl+J (LF) instead: both prompts take it as a new line, a shell still runs
the line.
2026-11-06 15:06:09 +07:00
nntrivi2001 33bb3eccb5 chore: v2.8.79 — token-gated and LAN-bound dashboards; pool login recovery 2026-11-06 15:01:53 +07:00
nntrivi2001 4a665da79e fix: a token-gated or LAN-bound dashboard works end to end (WSL mirrored)
- /ws-pty carries the dashboard token like /ws (the terminal never attached
  with DASHBOARD_TOKEN set), and so does everything that cannot send the
  header: images, downloads, exports, drops (withToken/tokenHeader). A
  refused upgrade answers 401/403 and is logged and counted by reason.
- The browser asks for a missing or wrong token (401 EUNAUTHORIZED) and takes
  ?token= from the link agenthub start prints, dropping it from the URL.
- The CLI sends the token (DASHBOARD_TOKEN, else the one the running server
  publishes 0600 in <data dir>/runtime/dashboard-token); MCP and statusline
  read the same file.
- DASHBOARD_HOST takes a list, and 127.0.0.1 is always listened on too, so a
  LAN-only bind keeps hooks and the CLI working.
- agenthub start --host/--allowed-hosts/--token are saved in
  <data dir>/network.json for every later start; a wide bind gets a token;
  every reachable URL is printed.
- tmux < 3.7 is an amber, once-per-version notice that says only the pane
  scrollbar is missing, with a ~/.local build instead of sudo make install.
- INSTALL: PATH belongs in ~/.profile for sudo -iu/ssh; WSL mirrored notes.
2026-11-06 14:59:48 +07:00
nntrivi2001 1e3b34ec03 fix(server-mode): a pool login never strands a member on a dead pair
A home whose Claude raced the server's refresh kept a spent pair the pool
no longer recognised; the next beat dropped the home, and pick() then
treated it as the member's own login forever — the member was asked to
/login while every other home worked. The pool now remembers hashes of
its past refresh tokens (a home holding one stays the pool's and gets the
newest pair), never drops a home while fetchOwner cannot answer, and
re-reads the record after each await so a spawn's placement is not lost.
2026-11-06 14:44:14 +07:00
nntrivi2001 decf176b4b chore: v2.8.78 — workspace and session sharing 2026-11-06 13:05:10 +07:00
nntrivi2001 dea74b777e fix(server-mode): a session-only share resumes in a free slot only
A start that names no slot means pane 0; for a user with only a session of
the workspace that was let through and answered ERUNLIVE whenever another
user's agent held pane 0 (found by the browser run). The client already
sends slot: "auto"; the scope now requires it.
2026-11-06 11:45:25 +07:00
nntrivi2001 18e574a765 feat(server-mode): pane presence and typing audit (sharing phase 4)
A pane's tab shows the other people who have it open (pane-presence.js,
in memory, fed by the /ws-pty attach; GET /api/lanes/:id/presence and the
pane_presence broadcast to the owner and full collaborators) and says
"Billed to <name>" for a pane a collaborator opened (startedBy on the run).
The first real keystroke of each attach into a shared workspace's pane by
anyone but its owner is audited as `type into <run id>` (mouse, focus and
terminal replies to tmux do not count). The pty socket now pings, so a
vanished browser is ended at the next re-check and leaves the presence list.
2026-11-06 11:32:25 +07:00
nntrivi2001 879a6bb02d feat(server-mode): share one session; scope collaborators' pane tokens (sharing phase 3)
A workspace's owner (or an admin) can share ONE session from its right-click
menu (GET/PUT /api/lanes/:id/sessions/:sid/shares, table session_shares,
audited). The user sees the workspace (access: "session") with only that
session, joins the pane holding it, resumes it on their own account in a
free slot, and uses the workspace's git and Files reads and git writes;
everything else of the workspace answers 403 ESESSIONSHARE (scope.js
sessionShareAllows in scopeGuard). Hook/runtime broadcasts stay with full
collaborators.

A collaborator's pane token (readable by the owner's Linux account) now acts
as them inside that workspace only, and never starts or bills a session
(auth.js paneLaneAllows, 403 EPANESCOPE); a Terminal tab the owner or an
admin opens acts as the owner again. Docs say plainly that under S3 a
session share hands over the owner's account inside the terminal.
2026-11-06 11:25:40 +07:00
nntrivi2001 1a365a45bd feat(server-mode): collaborators run sessions on their own account (sharing phase 2)
A user a workspace is shared with may now start and resume AI sessions in
it. The run uses THEIR OWN account — a Claude pool setup-token, a Codex/Cursor
pool API key, or an AI Proxy of theirs — never the owner's login or a pool
login (409 ENOCOLLABACCOUNT otherwise). Their quota decides (EQUOTA, their own
flag file, the sweep goes by payer).

Money is per payer by UTC hour (session_payers, server/lib/session-payers.js):
a session nobody else ran is billed as before; one a collaborator started is
theirs; a resume changes hands from the next hour. Only Claude books by the
hour, so a cross-payer resume of any other tool and a collaborator's
resumeLast are refused (409 ECOLLABRESUME); a Terminal tab stays the owner's.
Users page, details, quotas, Today (mine=1) and developer budgets count by
payer. Docs: SERVER-MODE.md, API.md, CLAUDE.md, plan status.
2026-11-06 11:10:05 +07:00
nntrivi2001 54001585e2 fix(server-mode): close the gaps the review of workspace sharing found
- A Terminal tab a collaborator opens records them (dashboard_runs.started_by):
  its pane token acts as them — and only while still shared with them —
  never as the workspace owner (userFromPaneToken).
- POST /api/run always starts the lane's AI tool: a collaborator gets
  ECOLLABSTART whatever the body says (shell: true no longer slips past).
- A collaborator disabled since no longer locks the share list: kept on it
  (shown disabled) or taken off; only a new addition must be enabled.
- The workspace home tells a collaborator to open an existing session and
  keeps Start disabled instead of failing on every submit.
2026-11-06 10:48:29 +07:00
nntrivi2001 0bf552b793 chore(openapi): regenerate openapi.yaml (stale since 11-02) 2026-11-06 10:43:16 +07:00
nntrivi2001 87ba935c6e feat(server-mode): share a workspace with other users (sharing phase 1)
A workspace's owner (or an admin) shares it from its ⋯ menu → Share…
(lane_shares, GET/PUT /api/lanes/:id/shares, audited). The users it is shared
with see it, its sessions, Changes, history and live updates, join and type
into its open terminals, commit and push — every check through scope.js
(canSeeLane, sessionWhere, laneWhere, broadcastAudience viewers). Rename,
reset, remove, delete and sharing stay the owner's or an admin's
(403 ENOTOWNER), and a collaborator starts no AI session there yet
(403 ECOLLABSTART; a plain terminal tab is fine). Budgets and Today stay on
owned rows only (ownedOnlySessionWhere). Taking a share back sends lane_access
to those users and their terminals close at the next attach re-check.

Under S3 a collaborator works as the owner's Linux account; the Share dialog
says so plainly (docs/plans/workspace-sharing/, R1 option a).
2026-11-06 10:43:16 +07:00
nntrivi2001 045e46e111 docs(plans): workspace sharing — self-review findings (R1 blocks) 2026-11-06 10:27:43 +07:00
nntrivi2001 e7573f13d9 docs(plans): workspace sharing — open questions answered 2026-11-06 10:23:40 +07:00
nntrivi2001 9d28807f2c docs(plans): workspace & session sharing — draft for review 2026-11-06 10:19:48 +07:00
nntrivi2001 e60ebe468b chore: v2.8.77 — pool accounts in Profiles, Changes panel on the workspace home 2026-11-06 10:07:52 +07:00
nntrivi2001 26977780cc feat(workspace): the home's context card follows the scroll; no scrollbar
On the workspace home the Instructions / Memory / Context / Folder card is
sticky inside the home's scroller (scrolling itself, barless, when taller
than the window), and the home column hides its scrollbar — wheel and touch
still scroll it.
2026-11-06 10:05:32 +07:00
nntrivi2001 e06472ecd9 fix(workspace): the home no longer scrolls the page into empty space
Each Recents row carries an absolutely placed sr-only label. Placed against
the row outside the home's own scroll column, those labels escaped its
clipping and stretched the page by the hidden part of the list — an empty
page-length scroll under the home and the Changes panel. The column is now
relative, so its scroller contains them.
2026-11-06 09:50:27 +07:00
nntrivi2001 ab27629139 feat(workspace): the Changes panel shows on the workspace home too
The home used to hide the whole row the Changes panel lives in, so a
workspace's changes showed only once a session tab was open. The home now
shares that row: the panel (or its rail) sits beside it at the same width,
the page is locked to the viewport and the home scrolls in its own column.
The panel's Workspace context fold is left out there, since the home shows
that column itself. The console's section stays mounted and hidden (CSS).
2026-11-06 09:42:08 +07:00
nntrivi2001 3b1fdfac51 feat(workspace): the Changes panel's repo sections are an accordion
With several repos stacked in the Changes panel, only one section is open at
a time: the first starts open, opening another folds the rest so it gets the
whole height, and clicking the open one folds it. A chat's open-file request
opens the section holding the file. Folded sections stay mounted (CSS), so an
unsaved edit survives.
2026-11-06 09:25:46 +07:00
nntrivi2001 087c1ddb5d feat(settings): pool accounts are rows of Profiles; the separate Pool account section is gone
Settings → AI tools → Profiles now lists the account-pool accounts the user
may use (GET /api/me/pool-accounts) as rows of each tool's tab, with quota,
an 'in use' badge and Switch to (PUT /api/me/pool-accounts), plus an
Automatic row; an admin's pin locks them. A saved profile with the same
email as a pool account is shown once, as the pool row. Developers now see
Profiles too, with only their pool rows; Cursor gets a tab when the pool
holds a Cursor account for the user.
2026-11-06 09:21:46 +07:00
156 changed files with 9250 additions and 1888 deletions
+6 -5
View File
File diff suppressed because one or more lines are too long
+33 -2
View File
@@ -57,20 +57,48 @@ sudo pacman -S git tmux # Arch
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
**PATH for `agenthub`:** put `export PATH="$HOME/.local/bin:$PATH"` in
`~/.profile`, not only `~/.bashrc`. A login shell that is not interactive —
`sudo -iu <user> agenthub remote login`, an `ssh host agenthub …`, a systemd
unit — reads `~/.profile`, and Ubuntu's `~/.bashrc` returns early when the shell
is not interactive, so a PATH set only there is never seen (`agenthub: command
not found`). Or call it by its full path, `~/.local/bin/agenthub`.
**WSL2 with `networkingMode=mirrored`:** on some machines a Windows browser
cannot reach WSL's `localhost`, so the dashboard must listen on an address
Windows can reach. Save it once — every later start, the autostart unit and an
update keep it:
```bash
agenthub start --host 0.0.0.0 # every interface (LAN and VPN too)
agenthub start --host 192.168.1.20 # or only this address (and 127.0.0.1)
```
A non-loopback bind always gets a token (generated when you give none; `--token
<t>` sets your own, `--token off` removes it) and `agenthub start` prints the
links to open, each carrying `?token=` — the page keeps it and drops it from
the address bar. The server also listens on `127.0.0.1` whenever the bind does
not cover it, so hooks and the CLI keep working. `agenthub start --host local`
goes back to loopback only.
On a native Windows install, workspace terminals are unavailable, but Settings →
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
and runs a `.cmd` updater or npm through `cmd.exe`.
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release:
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release
into your own `~/.local` — the system's tmux stays as it is:
```bash
sudo apt install build-essential pkg-config libevent-dev libncurses-dev bison
curl -fsSL https://github.com/tmux/tmux/releases/download/3.7c/tmux-3.7c.tar.gz | tar xz
cd tmux-3.7c && ./configure && make && sudo make install # into /usr/local/bin
cd tmux-3.7c && ./configure --prefix="$HOME/.local" && make && make install # into ~/.local/bin
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile # ahead of /usr/bin
```
(`sudo make install` instead puts it in `/usr/local/bin` for every account.)
A tmux server already running keeps its old version until every tmux session
has closed (or the machine reboots) — only then do the scrollbars appear.
@@ -444,6 +472,9 @@ npm run install-hooks
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
| `DASHBOARD_HOST` | `127.0.0.1` | Address(es) to listen on, comma-separated (`192.168.1.20,100.64.0.7`); `127.0.0.1` is added whenever none covers it. Saved by `agenthub start --host` in `<data dir>/network.json`; the variable wins over the file |
| `DASHBOARD_ALLOWED_HOSTS` | — | Extra `Host` names (comma-separated) for a LAN bind. Saved by `agenthub start --allowed-hosts` |
| `DASHBOARD_TOKEN` | — | Access token for `/api/*` and the WebSockets. Saved by `agenthub start --token`; the running server publishes it in `<data dir>/runtime/dashboard-token` (0600) for the local CLI, MCP server and statusline |
Example with a custom port:
+80 -1
View File
@@ -147,6 +147,11 @@ function baseUrl() {
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl();
}
/** The dashboard-token header (`dashboard-url.js`), {} when none is set. */
function dashboardTokenHeaders() {
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).tokenHeaders();
}
/** Thrown when the dashboard server does not answer — the dispatcher decides
* whether the active command has an offline fallback or must abort. */
class ServerDownError extends Error {}
@@ -162,6 +167,8 @@ async function api(method, pathname, body, options = {}) {
method,
headers: {
...(body && !form ? { "Content-Type": "application/json" } : {}),
// DASHBOARD_TOKEN, else the one the running server published.
...dashboardTokenHeaders(),
// 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 }
@@ -1495,6 +1502,64 @@ async function cmdReinstallHooks() {
console.log(`${c.green("✔")} Claude Code hooks reinstalled`);
}
/**
* Save `start`'s network flags into `<data dir>/network.json` (applied by every
* later server start): `--host A[,B]` (`local` = back to loopback only),
* `--allowed-hosts H,..` (`none` clears), `--token T|auto|off`. A wide bind
* with no token gets one made (`auto`), never left open silently. Returns
* whether anything changed.
*/
function saveNetworkFlags(flags) {
const net = require(path.join(REPO_ROOT, "server", "lib", "network-config.js"));
const patch = {};
if (typeof flags.host === "string") {
patch.host = flags.host === "local" ? null : flags.host;
} else if (flags.host === true) {
console.error(c.red("✖ --host needs an address, e.g. --host 0.0.0.0 (or --host local)"));
process.exit(1);
}
if (typeof flags["allowed-hosts"] === "string") {
patch.allowedHosts = flags["allowed-hosts"] === "none" ? null : flags["allowed-hosts"];
}
if (flags.token === "off") patch.token = null;
else if (flags.token === "auto" || flags.token === true) patch.token = net.newToken();
else if (typeof flags.token === "string") patch.token = flags.token;
if (Object.keys(patch).length === 0) {
net.applyNetworkConfig();
return false;
}
const saved = net.writeNetworkConfig(patch);
const wide = (saved.host || "")
.split(",")
.map((h) => h.trim())
.some((h) => h && !["127.0.0.1", "localhost", "::1", "[::1]"].includes(h));
if (wide && !saved.token && !process.env.DASHBOARD_TOKEN) {
// Reachable from the network with no token would be an open dashboard.
Object.assign(saved, net.writeNetworkConfig({ token: net.newToken() }));
console.log(c.yellow(" A token was generated: the dashboard is reachable from the network."));
}
console.log(`${c.green("✔")} Network settings saved to ${c.dim(net.configPath())}`);
net.applyNetworkConfig();
return true;
}
/**
* Print every URL the dashboard can be opened at (all of this machine's
* addresses for a 0.0.0.0 bind), each carrying `?token=` when one is set —
* the page stores it and drops it from the address bar.
*/
function printAccessUrls() {
const sec = require(path.join(REPO_ROOT, "server", "lib", "security.js"));
const port = Number(new URL(baseUrl()).port) || 4820;
const token = sec.getDashboardToken();
const urls = sec.accessUrls(port);
if (urls.length <= 1 && !token) return;
console.log(c.dim(" Open it at:"));
for (const u of urls) {
console.log(` ${c.bold(token ? `${u}/?token=${encodeURIComponent(token)}` : u)}`);
}
}
/**
* Start the dashboard server in the background (production mode, serving the
* built client) and wait until /api/health answers. No-ops with a pointer to
@@ -1505,8 +1570,16 @@ async function cmdReinstallHooks() {
async function cmdStart(flags) {
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
const changed = saveNetworkFlags(flags);
if (await serverIsUp()) {
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
if (changed) {
console.log(
c.yellow(" The new network settings were saved; they apply at the next start: ") +
c.bold("agenthub stop && agenthub start")
);
}
printAccessUrls();
return;
}
// A plugin install starts the way a Claude Code session does: its bootstrap
@@ -1523,6 +1596,7 @@ async function cmdStart(flags) {
while (Date.now() < deadline) {
if (await serverIsUp()) {
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
printAccessUrls();
return;
}
await new Promise((r) => setTimeout(r, 500));
@@ -1572,6 +1646,7 @@ async function cmdStart(flags) {
console.log(
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
);
printAccessUrls();
console.log(c.dim(" Stop it with: agenthub stop"));
return;
}
@@ -2928,7 +3003,11 @@ const COMMAND_GROUPS = [
"Server",
[
["status", "", "Up/down indicator for the dashboard server"],
["start", "[--port N]", "Start the server in the background and wait for healthy"],
[
"start",
"[--port N] [--host A[,B]|local] [--allowed-hosts H,..] [--token T|auto|off]",
"Start the server in the background and wait for healthy; --host/--allowed-hosts/--token are saved (data dir network.json) for every later start",
],
[
"stop",
"",
+88 -1
View File
@@ -11,6 +11,9 @@
* `usePermissions()` says what that user's role may do, so pages hide the rest.
* Once an account is known (local mode is one), its own UI preferences are
* loaded into this browser (`lib/userPrefs.ts`).
* With DASHBOARD_TOKEN set, a 401 `EUNAUTHORIZED` (from `/auth/me` or any
* request, `TOKEN_REQUIRED_EVENT`) shows the token prompt instead: the token is
* kept in this browser and the page reloads with it.
* The login page follows the OS's light/dark (`AuthCard`). An account whose
* password an admin gave (`mustChangePassword`) sees only
* `FirstPasswordPage` until it picks its own.
@@ -30,7 +33,15 @@ import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import { AuthCard } from "./AuthCard";
import { FirstPasswordPage } from "./FirstPasswordPage";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
import {
api,
AUTH_REQUIRED_EVENT,
dashboardToken,
saveDashboardToken,
TOKEN_REQUIRED_EVENT,
type AuthState,
type AuthUser,
} from "../lib/api";
import { hydrate } from "../lib/userPrefs";
type AuthValue = {
@@ -72,6 +83,10 @@ export function usePermissions() {
// A developer only ever sees their own rows, so everything shown is theirs.
canEditLane: (lane?: { owner_id?: number | null } | null) =>
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
// Owner-only actions on a workspace (rename, reset, remove, share): its
// owner or an admin. A workspace shared with this user is not theirs.
ownsLane: (lane?: { owner_id?: number | null } | null) =>
isAdmin || (!!user && lane?.owner_id === user.id),
};
}
@@ -91,6 +106,8 @@ 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);
// DASHBOARD_TOKEN is set and this browser's token is missing or wrong.
const [needToken, setNeedToken] = useState(false);
useEffect(() => {
let timer: number | undefined;
@@ -109,6 +126,10 @@ export function AuthGate({ children }: { children: ReactNode }) {
})
.catch((err: { status?: number; code?: string }) => {
if (!alive) return;
if (err?.status === 401 && err?.code === "EUNAUTHORIZED") {
setNeedToken(true);
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
@@ -130,10 +151,13 @@ export function AuthGate({ children }: { children: ReactNode }) {
// 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);
const onToken = () => setNeedToken(true);
window.addEventListener(TOKEN_REQUIRED_EVENT, onToken);
return () => {
alive = false;
window.clearTimeout(timer);
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
window.removeEventListener(TOKEN_REQUIRED_EVENT, onToken);
};
}, []);
@@ -158,6 +182,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
[]
);
if (needToken) return <TokenPage />;
if (!state) return null;
if (state.mode === "server" && !state.user) {
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
@@ -262,3 +287,65 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
</AuthCard>
);
}
/**
* DASHBOARD_TOKEN's own prompt: the token is kept in this browser
* (`localStorage.dashboard_token`) and the page reloads, so every request,
* WebSocket and image starts again with it. A token already saved here that
* the server refused is shown as wrong.
*/
function TokenPage() {
const { t } = useTranslation();
const [token, setToken] = useState("");
const [show, setShow] = useState(false);
const hadOne = Boolean(dashboardToken());
const submit = (e: FormEvent) => {
e.preventDefault();
const value = token.trim();
if (!value) return;
saveDashboardToken(value);
window.location.reload();
};
return (
<AuthCard title={t("auth.tokenTitle")} subtitle={t("auth.tokenSubtitle")} onSubmit={submit}>
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
<label htmlFor="dashboard-token">{t("auth.tokenLabel")}</label>
<div className="relative">
<input
id="dashboard-token"
className="input h-11 w-full pl-4 pr-11 font-mono"
type={show ? "text" : "password"}
autoComplete="off"
autoFocus
required
value={token}
onChange={(e) => setToken(e.target.value)}
/>
<button
type="button"
onClick={() => setShow((v) => !v)}
aria-label={show ? t("auth.hidePassword") : t("auth.showPassword")}
aria-pressed={show}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{hadOne && (
<p role="alert" className="text-sm text-status-danger">
{t("auth.tokenWrong")}
</p>
)}
<button
type="submit"
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0"
>
{t("auth.tokenSubmit")}
</button>
<p className="text-center text-sm text-fg-secondary">{t("auth.tokenHint")}</p>
</AuthCard>
);
}
+10 -2
View File
@@ -8,18 +8,26 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Check, Info, X } from "lucide-react";
import { AlertCircle, AlertTriangle, Check, Info, X } from "lucide-react";
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
const TONE: Record<ToastItem["kind"], string> = {
success: "border-status-success/40 bg-status-success/10 text-status-success",
info: "border-border bg-surface-2 text-fg-primary",
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
warning: "border-status-warning/50 bg-status-warning/10 text-fg-primary",
};
function Item({ item }: { item: ToastItem }) {
const { t } = useTranslation("common");
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
const Icon =
item.kind === "error"
? AlertCircle
: item.kind === "warning"
? AlertTriangle
: item.kind === "info"
? Info
: Check;
return (
<div
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
+10 -2
View File
@@ -90,6 +90,9 @@ import { UserAvatar } from "./users/UserAvatar";
const AddLaneModal = lazy(() =>
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
);
const WorkspaceShareModal = lazy(() =>
import("./lanes/WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
);
const DestructiveLaneModal = lazy(() =>
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
);
@@ -116,7 +119,9 @@ export function WorkspaceTree() {
const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useLanes();
const { state: auth } = useAuth();
const { seesAll, canEditLane } = usePermissions();
const { seesAll, canEditLane, ownsLane } = usePermissions();
// Sharing a workspace with other users (server mode): the open dialog's lane.
const [sharing, setSharing] = useState<Lane | null>(null);
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
// Server mode staff see every user's workspaces, so the list says whose.
const grouped = auth.mode === "server" && seesAll;
@@ -375,6 +380,7 @@ export function WorkspaceTree() {
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
if (action === "rename") setRenamingId(lane.id);
else if (action === "share") setSharing(lane);
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
};
@@ -559,7 +565,8 @@ export function WorkspaceTree() {
onNewSession={() => select(lane.id)}
onReorder={nudge}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
// A workspace shared with this user keeps its owner's name.
onRenameStart={() => ownsLane(lane) && setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
@@ -688,6 +695,7 @@ export function WorkspaceTree() {
</ContextMenu>
)}
<Suspense fallback={null}>
{sharing && <WorkspaceShareModal lane={sharing} onClose={() => setSharing(null)} />}
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
@@ -13,7 +13,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../i18n";
@@ -27,6 +27,9 @@ const LANES = [
];
vi.mock("../../lib/api", () => ({
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
TOKEN_REQUIRED_EVENT: "agenthub:token-required",
dashboardToken: () => localStorage.getItem("dashboard_token"),
saveDashboardToken: (t: string) => localStorage.setItem("dashboard_token", t),
api: {
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
@@ -117,12 +120,42 @@ describe("AuthGate", () => {
}
});
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" },
]) {
it("asks for the dashboard token when DASHBOARD_TOKEN refuses this browser", async () => {
me.mockRejectedValue(Object.assign(new Error("x"), { status: 401, code: "EUNAUTHORIZED" }));
const reload = vi.fn();
const loc = window.location;
// @ts-expect-error test override
delete window.location;
// @ts-expect-error test override
window.location = { ...loc, reload };
try {
render(<AuthGate>app</AuthGate>);
const input = await screen.findByLabelText("Token");
expect(screen.queryByText("app")).not.toBeInTheDocument();
expect(screen.queryByText(/token was not accepted/)).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: " s3cret " } });
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
expect(localStorage.getItem("dashboard_token")).toBe("s3cret");
expect(reload).toHaveBeenCalled();
} finally {
// @ts-expect-error test override
window.location = loc;
}
});
it("says a saved token was refused, and asks on any request's 401 too", async () => {
localStorage.setItem("dashboard_token", "old");
me.mockResolvedValue({ mode: "local" });
render(<AuthGate>app</AuthGate>);
expect(await screen.findByText("app")).toBeInTheDocument();
act(() => {
window.dispatchEvent(new Event("agenthub:token-required"));
});
expect(await screen.findByText(/token was not accepted/)).toBeInTheDocument();
});
it("renders the app when an older server answers instead", async () => {
for (const err of [{ status: 404 }, { status: 200, code: "EBADRESPONSE" }]) {
me.mockReset();
me.mockRejectedValue(Object.assign(new Error("x"), err));
const { unmount } = render(<AuthGate>app</AuthGate>);
@@ -0,0 +1,82 @@
/**
* @file ServerSettings.tsx
* @description Administration → **Server settings** (server mode, an admin
* only): every setting that acts on the server for everyone, moved out of
* Settings so a user's Settings holds only their own things. Laptop
* connections and every user's enrolled machines, members' test runs (S3),
* the shared Oh My Pi login (S3), Artifacts Hub installs, the host's git
* logins, scheduled reports, start at login / the icon / Quit, model pricing
* and the data tools. Each block keeps its own component (each still checks
* the role and hides itself where it does not apply); in local mode these
* stay in Settings. A `#anchor` in the URL scrolls to its block.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { RemotePolicy } from "../remote/RemotePolicy";
import { RemoteDevices } from "../remote/RemoteDevices";
import { MemberPolicySettings } from "../settings/MemberPolicySettings";
import { OmpSharingSettings } from "../settings/OmpSharingSettings";
import { ArtifactsHubSettings } from "../settings/ArtifactsHubSettings";
import { ReportSettings } from "../settings/ReportSettings";
import { AutostartSettings } from "../settings/AutostartSettings";
import { AllPricingSettings } from "../settings/PricingSettings";
import { SettingsSection } from "../settings/SettingsRow";
import { GitCredentialsConfig } from "../../pages/GitCredentialsConfig";
import { Settings } from "../../pages/Settings";
function Block({ id, children }: { id: string; children: ReactNode }) {
return (
<div id={id} className="scroll-mt-24">
{children}
</div>
);
}
export function ServerSettings() {
const { t } = useTranslation("settings");
const { hash } = useLocation();
useEffect(() => {
if (!hash) return;
const t1 = window.setTimeout(() => {
document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView({
block: "start",
});
}, 50);
return () => window.clearTimeout(t1);
}, [hash]);
return (
<div className="max-w-4xl space-y-6" data-testid="server-settings">
<Block id="company-hub">
<SettingsSection
id="company-hub-admin"
title={t("companyHub.adminTitle")}
description={t("companyHub.adminDescription")}
>
<RemotePolicy />
<RemoteDevices canRevoke />
</SettingsSection>
</Block>
{/* These carry their own anchors (member-policy, omp-sharing, artifacts-hub). */}
<MemberPolicySettings />
<OmpSharingSettings />
<ArtifactsHubSettings />
<Block id="git-credentials">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
<GitCredentialsConfig />
</Block>
<Block id="reports">
<ReportSettings />
</Block>
<AutostartSettings />
<Block id="pricing">
<AllPricingSettings />
</Block>
{/* Its own anchor: #data. */}
<Settings section="data" />
</div>
);
}
@@ -0,0 +1,89 @@
/**
* @file ServerSettings.test.tsx
* @description Administration → Server settings: every server-wide block is
* there (laptop connections + every machine, members' tests, omp sharing,
* Artifacts installs, git logins, reports, start at login, pricing, data),
* and an admin turns laptop connections on after one confirm (no password).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const stub = vi.hoisted(() => (id: string) => () => <div data-testid={id} />);
vi.mock("../../settings/MemberPolicySettings", () => ({
MemberPolicySettings: stub("member-policy"),
}));
vi.mock("../../settings/OmpSharingSettings", () => ({ OmpSharingSettings: stub("omp-sharing") }));
vi.mock("../../settings/ArtifactsHubSettings", () => ({
ArtifactsHubSettings: stub("artifacts-hub"),
}));
vi.mock("../../settings/ReportSettings", () => ({ ReportSettings: stub("reports") }));
vi.mock("../../settings/AutostartSettings", () => ({ AutostartSettings: stub("autostart") }));
vi.mock("../../settings/PricingSettings", () => ({ AllPricingSettings: stub("pricing") }));
vi.mock("../../../pages/GitCredentialsConfig", () => ({ GitCredentialsConfig: stub("git-creds") }));
vi.mock("../../../pages/Settings", () => ({ Settings: stub("data") }));
vi.mock("../../remote/RemoteDevices", () => ({ RemoteDevices: stub("remote-devices") }));
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
const api = vi.hoisted(() => ({
remoteHub: { policy: vi.fn(), setPolicy: vi.fn() },
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api };
});
import { ServerSettings } from "../ServerSettings";
import { confirmDialog } from "../../../lib/confirm";
const show = () =>
render(
<MemoryRouter>
<ServerSettings />
</MemoryRouter>
);
describe("ServerSettings", () => {
it("holds every server-wide block", async () => {
api.remoteHub.policy.mockResolvedValue({ allowDevices: false, changedAt: null });
show();
for (const id of [
"remote-devices",
"member-policy",
"omp-sharing",
"artifacts-hub",
"git-creds",
"reports",
"autostart",
"pricing",
"data",
]) {
expect(screen.getByTestId(id)).toBeInTheDocument();
}
expect(await screen.findByRole("switch", { name: /laptop/i })).toHaveAttribute(
"aria-checked",
"false"
);
});
it("turns laptop connections on after a confirm, with no password", async () => {
api.remoteHub.policy.mockResolvedValue({ allowDevices: false, changedAt: null });
api.remoteHub.setPolicy.mockResolvedValue({
allowDevices: true,
changedAt: "2026-11-09T04:00:00.000Z",
});
const user = userEvent.setup();
show();
const sw = await screen.findByRole("switch", { name: /laptop/i });
await user.click(sw);
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0]).toMatchObject({ destructive: true });
await waitFor(() => expect(api.remoteHub.setPolicy).toHaveBeenCalledWith(true));
expect(api.remoteHub.setPolicy.mock.calls[0]).toHaveLength(1);
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
});
});
@@ -2,7 +2,9 @@
* @file MarkdownContent.tsx
* @description Lightweight markdown renderer for conversation messages. Supports the
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* fenced code blocks, ATX headings, ordered/unordered lists (nested by indent; a
* numbered list split by a blank line keeps counting from its own numbers),
* task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
@@ -68,7 +70,7 @@ import { onLinkClick } from "../../lib/openInPreview";
type Block =
| { kind: "code"; lang: string; code: string }
| { kind: "heading"; level: number; text: string }
| { kind: "list"; ordered: boolean; items: string[] }
| { kind: "list"; ordered: boolean; start: number; items: string[] }
| { kind: "quote"; text: string }
| { kind: "hr" }
| {
@@ -196,27 +198,35 @@ function parseBlocks(src: string): Block[] {
if (ulMatch || olMatch) {
const ordered = !!olMatch;
const itemRe = ordered ? OL_RE : UL_RE;
const base = (olMatch ?? ulMatch)![1]!.length;
// The number the list starts at: a list broken by a blank line or a
// stray block still counts on from where the text says ("2.", "3.").
const start = olMatch ? Number(olMatch[2]) || 1 : 1;
const items: string[] = [];
while (i < lines.length) {
const m = lines[i]!.match(itemRe);
if (m) {
if (m && m[1]!.length <= base) {
items.push(m[3]!);
i++;
// The item's own lines: indented text, and a list nested under it
// (indented deeper than this list's marker) — kept with its
// relative indent so the item renders it as a sub-list.
while (
i < lines.length &&
lines[i]!.trim() !== "" &&
!lines[i]!.match(UL_RE) &&
!lines[i]!.match(OL_RE) &&
/^\s+\S/.test(lines[i]!)
/^\s+\S/.test(lines[i]!) &&
(lines[i]!.match(/^\s*/)![0].length > base ||
(!lines[i]!.match(UL_RE) && !lines[i]!.match(OL_RE)))
) {
items[items.length - 1] += "\n" + lines[i]!.trim();
const nested = UL_RE.test(lines[i]!) || OL_RE.test(lines[i]!);
items[items.length - 1] += "\n" + (nested ? lines[i]! : lines[i]!.trim());
i++;
}
} else {
break;
}
}
blocks.push({ kind: "list", ordered, items });
blocks.push({ kind: "list", ordered, start, items });
continue;
}
@@ -524,8 +534,27 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
return out;
}
/** Render a single list item, handling [ ] / [x] task list prefixes. */
/** Render a single list item: its text, then any list nested under it. */
function renderListItem(item: string, key: string): React.ReactNode {
const lines = item.split("\n");
const sub = lines.findIndex((l, n) => n > 0 && (UL_RE.test(l) || OL_RE.test(l)));
if (sub > 0) {
const rest = lines.slice(sub);
const indent = Math.min(...rest.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
return (
<>
{renderItemText(lines.slice(0, sub).join("\n"), key)}
<div className="mt-1">
<MarkdownContent text={rest.map((l) => l.slice(indent)).join("\n")} dense />
</div>
</>
);
}
return renderItemText(item, key);
}
/** An item's own text, handling [ ] / [x] task list prefixes. */
function renderItemText(item: string, key: string): React.ReactNode {
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
if (taskMatch) {
const checked = taskMatch[1]!.toLowerCase() === "x";
@@ -594,6 +623,7 @@ export function MarkdownContent({ text, dense = false, prose = false }: Markdown
return (
<ol
key={idx}
start={b.start !== 1 ? b.start : undefined}
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
>
{b.items.map((item, i) => (
@@ -118,7 +118,7 @@ import {
} from "lucide-react";
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
import { api } from "../../lib/api";
import { api, withToken } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
/** The icon of a transcript row's sender. A JSONL `type:"user"` line is not
@@ -1656,7 +1656,7 @@ function DropFile({ path }: { path: string }) {
function DropImage({ name }: { name: string }) {
const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false);
const src = `/api/run/drop/${encodeURIComponent(name)}`;
const src = withToken(`/api/run/drop/${encodeURIComponent(name)}`);
// Drops are pruned after a day: the name is all that is left to show.
if (gone)
return (
@@ -89,6 +89,37 @@ describe("<MarkdownContent />", () => {
expect(container.querySelectorAll("ol li")).toHaveLength(2);
});
it("keeps one numbered list when its items carry indented bullets", () => {
const text = [
"Có 4 điểm:",
"",
"1. **yt-dlp**",
" - Hướng dẫn",
" - YouTube",
"2. **Nút Setup**",
" - site-packages",
"3. **Log khởi động**",
" Có thể bỏ dòng này.",
"4. **Nên kiểm tra**",
].join("\n");
const { container } = render(<MarkdownContent text={text} />);
const top = container.querySelectorAll(":scope > div > ol");
expect(top).toHaveLength(1);
expect(top[0]!.querySelectorAll(":scope > li")).toHaveLength(4);
expect(top[0]!.querySelectorAll("ul li")).toHaveLength(3);
expect(screen.getByText(/Có thể bỏ dòng này/)).toBeInTheDocument();
});
it("continues the numbering of a list split by a blank line or a block", () => {
const { container } = render(
<MarkdownContent text={"1. first\n\n```\ncode\n```\n\n2. second\n3. third"} />
);
const lists = container.querySelectorAll("ol");
expect(lists).toHaveLength(2);
expect(lists[0]!.getAttribute("start")).toBeNull();
expect(lists[1]!.getAttribute("start")).toBe("2");
});
it("renders blockquotes", () => {
const { container } = render(<MarkdownContent text={"> a quote"} />);
expect(container.querySelector("blockquote")).not.toBeNull();
@@ -31,6 +31,7 @@ export function SessionMenu({
onArchive,
onUnarchive,
onPin,
onShare,
}: MenuPoint & {
laneId: number;
session: Session | null;
@@ -42,6 +43,8 @@ export function SessionMenu({
onUnarchive?: () => void;
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
onPin?: () => void;
/** Share this session on its own (server mode, the workspace's owner). */
onShare?: () => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -81,6 +84,11 @@ export function SessionMenu({
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
</MenuItem>
)}
{onShare && session && (
<MenuItem testId="session-menu-share" onSelect={pick(onShare)}>
{t("sessionMenu.share")}
</MenuItem>
)}
{session && (
<>
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
@@ -2,7 +2,8 @@
* @file WorkspaceContext — a workspace's context: Instructions, Memory
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
* workspace home's right column and, `compact`, folded at the bottom of the
* workspace home's right column (sticky: it follows the home's scroll) and,
* `compact`, folded at the bottom of the
* session view's Changes panel. Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
* same form clones related repos beside the workspace (`CloneSourcesForm`).
@@ -157,7 +158,13 @@ function ContextBody({
<aside
data-testid="workspace-context"
aria-label={t("home.contextPanel")}
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
// On the home it follows the scroll (sticky in the home's scroller),
// scrolling itself — without a bar — when taller than the window.
className={
compact
? ""
: "card no-scrollbar sticky top-2 max-h-[calc(100vh-7rem)] w-80 shrink-0 self-start overflow-y-auto p-0"
}
>
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
+26 -3
View File
@@ -57,6 +57,8 @@ import { AiToolIcon } from "../AiToolIcon";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { WorkspaceShareModal } from "./WorkspaceShareModal";
import { usePermissions } from "../AuthGate";
import { workspaceName } from "./WorkspaceRow";
import { SessionMenu } from "./SessionMenu";
import { menuPoint, useContextMenu } from "../ContextMenu";
@@ -118,6 +120,7 @@ function HomeBody({
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const aiTools = useAiTools();
const { ownsLane } = usePermissions();
// The NEW session's AI tool — its own choice, the workspace's by default.
const [tool, setTool] = useState(lane.tool);
const entry = aiTools.byId(tool);
@@ -195,6 +198,7 @@ function HomeBody({
const [nameDraft, setNameDraft] = useState("");
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const [sharing, setSharing] = useState(false);
const recentCtx = useContextMenu<Session>();
// A new pane, a pane gone, a session id learned — all move these lane fields.
@@ -270,10 +274,17 @@ function HomeBody({
};
const text = promptWithImages(prompt, images, claude);
// A workspace shared with this user: a session they start runs on their
// own account and is billed to them (the server answers ENOCOLLABACCOUNT
// when they have none).
const collab = !ownsLane(lane);
// Only a session of it is shared with them (phase 3): no new session here —
// they open that session and send it messages (the server answers ESESSIONSHARE).
const sessionOnly = lane.access === "session";
const start = async () => {
// Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false.
if (startingRef.current || uploading || !text) return;
if (sessionOnly || startingRef.current || uploading || !text) return;
startingRef.current = true;
setStarting(true);
setError(null);
@@ -529,7 +540,7 @@ function HomeBody({
type="button"
data-testid="workspace-home-start"
onClick={() => void start()}
disabled={starting || uploading || !text}
disabled={sessionOnly || starting || uploading || !text}
aria-label={t("home.start")}
title={t("home.start")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
@@ -539,6 +550,11 @@ function HomeBody({
</div>
</div>
</div>
{collab && (
<p data-testid="workspace-home-collab" className="mt-2 text-xs text-fg-muted">
{t(sessionOnly ? "shared.sessionOnly" : "shared.ownAccount")}
</p>
)}
{/* The session's quieter choices, under the box like Claude Desktop's. */}
<div
data-testid="workspace-home-options"
@@ -787,9 +803,16 @@ function HomeBody({
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
onAction={(a) =>
a === "rename"
? setTitle(lane.title || "")
: a === "share"
? setSharing(true)
: onMenuAction(a)
}
/>
)}
{sharing && <WorkspaceShareModal lane={lane} onClose={() => setSharing(false)} />}
</div>
);
}
@@ -2,11 +2,13 @@
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Rename (the row's inline field), New workspace here (the
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* Add modal prefilled), Copy path, Share (server mode: `WorkspaceShareModal`),
* and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane — plus Move to group (the sidebar's own groups,
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
* server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position,
* what the lane's kind or tool makes meaningless, and the owner-only items
* (Rename, Share, Reset, Remove) on a workspace shared with this user. The shell (position,
* Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -17,8 +19,9 @@ import { Check } from "lucide-react";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
import { copyText } from "../../lib/clipboard";
import { usePermissions } from "../AuthGate";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
export type WorkspaceMenuAction = "rename" | "new-here" | "share" | "reset" | "remove";
export function WorkspaceRowMenu({
lane,
@@ -43,9 +46,11 @@ export function WorkspaceRowMenu({
onGroup?: (target: string | null | "new") => void;
}) {
const { t } = useTranslation("lanes");
const { local, ownsLane } = usePermissions();
const owner = ownsLane(lane);
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
{owner && <MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>}
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem
onSelect={() => {
@@ -55,6 +60,11 @@ export function WorkspaceRowMenu({
>
{t("menu.copyPath")}
</MenuItem>
{owner && !local && (
<MenuItem testId="workspace-menu-share" onSelect={() => onAction("share")}>
{t("menu.share")}
</MenuItem>
)}
{onGroup && (
<>
<MenuSeparator />
@@ -84,15 +94,19 @@ export function WorkspaceRowMenu({
)}
</>
)}
<MenuSeparator />
{lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}>
{t("menu.reset")}
</MenuItem>
{owner && (
<>
<MenuSeparator />
{lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}>
{t("menu.reset")}
</MenuItem>
)}
<MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")}
</MenuItem>
</>
)}
<MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")}
</MenuItem>
</ContextMenu>
);
}
@@ -31,7 +31,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
import { endRuns } from "../../lib/stopSession";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
@@ -46,6 +46,11 @@ import { useAiTools } from "../../hooks/useAiTools";
import { useThrottledValue } from "../../hooks/useThrottledValue";
import { useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
import { usePermissions } from "../AuthGate";
const WorkspaceShareModal = lazy(() =>
import("./WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
);
export const SESSION_LIMIT = 6;
@@ -256,6 +261,9 @@ export function WorkspaceSessionList({
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<RunHandle>();
const sessionCtx = useContextMenu<Session>();
// Share one session (server mode, the workspace's owner or an admin).
const { local, ownsLane } = usePermissions();
const [sharing, setSharing] = useState<Session | null>(null);
// Rows shown; See all raises it to the whole list.
const [limit, setLimit] = useState(SESSION_LIMIT);
@@ -637,8 +645,18 @@ export function WorkspaceSessionList({
onPin={() =>
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
}
onShare={!local && ownsLane(lane) ? () => setSharing(sessionCtx.menu!.item) : undefined}
/>
)}
{sharing && (
<Suspense fallback={null}>
<WorkspaceShareModal
lane={lane}
session={{ id: sharing.id, name: sharing.name }}
onClose={() => setSharing(null)}
/>
</Suspense>
)}
</>
);
}
@@ -0,0 +1,220 @@
/**
* @file WorkspaceShareModal — share a workspace, or one of its sessions
* (`session`), with other users (server mode, docs/plans/workspace-sharing/
* phases 1 and 3). Ticks people from `GET /api/artifacts/people` (names only)
* and saves the whole list through `PUT /api/lanes/:id/shares` or
* `PUT /api/lanes/:id/sessions/:sid/shares`. It says plainly what sharing means on this
* server: the people it is shared with work as the owner's account and can
* read everything in the owner's home. Only the owner or an admin opens it —
* the server refuses anyone else (`ENOTOWNER`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert, X } from "lucide-react";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { Checkbox } from "../Checkbox";
type Person = { id: number; username: string; name: string; disabled?: boolean };
export function WorkspaceShareModal({
lane,
session,
onClose,
}: {
lane: Lane;
/** Share this one session instead of the whole workspace. */
session?: { id: string; name?: string | null };
onClose: () => void;
}) {
const { t } = useTranslation("lanes");
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const [people, setPeople] = useState<Person[] | null>(null);
const [initial, setInitial] = useState<Set<number>>(new Set());
const [picked, setPicked] = useState<Set<number>>(new Set());
const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
let alive = true;
const sid = session?.id;
Promise.all([
api.artifacts.people(),
sid ? api.lanes.sessionShares(lane.id, sid) : api.lanes.shares(lane.id),
])
.then(([p, s]) => {
if (!alive) return;
// Never the owner: a workspace is not shared with them. Someone it is
// already shared with but who was disabled since is not in `people`:
// listed anyway, so they can be taken off.
const known = new Set(p.people.map((x) => x.id));
setPeople([
...p.people.filter((x) => x.id !== lane.owner_id),
...s.shares.filter((x) => !known.has(x.id)).map((x) => ({ ...x, disabled: true })),
]);
const ids = new Set(s.shares.map((x) => x.id));
setInitial(ids);
setPicked(new Set(ids));
})
.catch((err) => {
if (!alive) return;
toast.error(err);
setPeople([]);
});
return () => {
alive = false;
};
}, [lane.id, lane.owner_id, session?.id]);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
return (people ?? []).filter(
(p) => !q || p.name.toLowerCase().includes(q) || p.username.toLowerCase().includes(q)
);
}, [people, query]);
const added = [...picked].filter((id) => !initial.has(id));
const changed = added.length > 0 || [...initial].some((id) => !picked.has(id));
const name = session ? session.name || session.id : lane.title || lane.cwd;
const save = async () => {
if (added.length > 0) {
const names = (people ?? []).filter((p) => added.includes(p.id)).map((p) => p.name);
const ok = await confirmDialog({
title: t("share.confirmTitle", { name }),
message: t("share.confirmBody", { people: names.join(", ") }),
confirmLabel: t("share.confirm"),
});
if (!ok) return;
}
setBusy(true);
try {
const r = session
? await api.lanes.setSessionShares(lane.id, session.id, [...picked])
: await api.lanes.setShares(lane.id, [...picked]);
toast.success(
r.shares.length ? t("share.saved", { count: r.shares.length }) : t("share.savedNone")
);
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
};
const toggle = (id: number, on: boolean) =>
setPicked((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="workspace-share-title"
data-testid="workspace-share-modal"
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
>
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h2 id="workspace-share-title" className="text-base font-semibold text-fg-primary">
{t(session ? "share.sessionTitle" : "share.title")}
</h2>
<p className="mt-0.5 truncate text-xs text-fg-muted" title={lane.cwd}>
{name}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("share.close")}
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
<p className="text-xs text-fg-secondary">
{t(session ? "share.sessionWhat" : "share.what")}
</p>
<div
data-testid="workspace-share-warning"
className="flex gap-2 rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning"
>
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("share.warning")}</span>
</div>
{people === null ? (
<p className="text-xs text-fg-muted">{t("share.loading")}</p>
) : people.length === 0 ? (
<p className="text-xs text-fg-muted">{t("share.nobody")}</p>
) : (
<>
{people.length > 8 && (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("share.search")}
aria-label={t("share.search")}
className="input w-full text-sm"
/>
)}
<ul className="space-y-1" data-testid="workspace-share-people">
{shown.map((p) => (
<li key={p.id}>
<Checkbox
checked={picked.has(p.id)}
onChange={(v) => toggle(p.id, v)}
disabled={busy}
label={
<span>
{p.name}
{p.name !== p.username && (
<span className="ml-1.5 text-fg-muted">@{p.username}</span>
)}
{p.disabled && (
<span className="ml-1.5 text-fg-muted">{t("share.disabled")}</span>
)}
</span>
}
/>
</li>
))}
</ul>
</>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("share.cancel")}
</button>
<button
type="button"
className="btn-primary text-sm"
data-testid="workspace-share-save"
onClick={() => void save()}
disabled={busy || !changed || people === null}
>
{busy ? t("share.saving") : t("share.save")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,199 @@
/**
* @file Tests for workspace sharing in the client: WorkspaceShareModal starts
* on the current list, never offers the owner, warns what sharing means, asks
* once before adding someone and saves the WHOLE list; WorkspaceRowMenu offers
* Share (server mode) and the other owner-only items only to the workspace's
* owner or an admin; given a session, it lists and saves THAT session's
* shares instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act, fireEvent } from "@testing-library/react";
import "../../../i18n";
import i18n from "../../../i18n";
import { settleConfirm } from "../../../lib/confirm";
import { WorkspaceShareModal } from "../WorkspaceShareModal";
import { WorkspaceRowMenu } from "../WorkspaceRowMenu";
import type { Lane } from "../../../lib/types";
const perms = vi.hoisted(() => ({ local: false, me: 1, admin: false }));
vi.mock("../../AuthGate", () => ({
usePermissions: () => ({
local: perms.local,
isAdmin: perms.admin,
ownsLane: (l?: { owner_id?: number | null } | null) =>
perms.local || perms.admin || l?.owner_id === perms.me,
}),
}));
const m = vi.hoisted(() => ({
people: vi.fn(),
shares: vi.fn(),
setShares: vi.fn(),
sessionShares: vi.fn(),
setSessionShares: vi.fn(),
}));
vi.mock("../../../lib/api", () => ({
api: {
artifacts: { people: m.people },
lanes: {
shares: m.shares,
setShares: m.setShares,
sessionShares: m.sessionShares,
setSessionShares: m.setSessionShares,
},
},
}));
const lane = {
id: 7,
title: "Shop",
cwd: "/home/alice/shop",
owner_id: 1,
kind: "adopted",
} as Lane;
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
beforeEach(() => {
i18n.changeLanguage("en");
perms.local = false;
perms.me = 1;
perms.admin = false;
m.people.mockResolvedValue({
people: [
{ id: 1, username: "alice", name: "Alice" },
{ id: 2, username: "bob", name: "Bob" },
{ id: 3, username: "carol", name: "Carol" },
],
});
m.shares.mockResolvedValue({
shares: [{ id: 2, username: "bob", name: "Bob", sharedAt: "2026-11-06T00:00:00Z" }],
canManage: true,
});
m.setShares.mockImplementation(async (_id: number, ids: number[]) => ({
shares: ids.map((id) => ({ id, username: `u${id}`, name: `U${id}`, sharedAt: "" })),
}));
});
describe("WorkspaceShareModal", () => {
it("starts on the current list, never offers the owner and says what sharing means", async () => {
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
await settle();
const list = screen.getByTestId("workspace-share-people");
expect(list).not.toHaveTextContent("Alice");
expect(screen.getByRole("checkbox", { name: /Bob/ })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("checkbox", { name: /Carol/ })).toHaveAttribute(
"aria-checked",
"false"
);
expect(screen.getByTestId("workspace-share-warning")).toHaveTextContent(/your home folder/i);
// Nothing changed yet: nothing to save.
expect(screen.getByTestId("workspace-share-save")).toBeDisabled();
});
it("asks once before adding someone, then saves the whole list", async () => {
const onClose = vi.fn();
render(<WorkspaceShareModal lane={lane} onClose={onClose} />);
await settle();
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
fireEvent.click(screen.getByTestId("workspace-share-save"));
await settle();
expect(m.setShares).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await settle();
expect(m.setShares).toHaveBeenCalledWith(7, [2, 3]);
expect(onClose).toHaveBeenCalled();
});
it("lists a collaborator disabled since, so they can be taken off", async () => {
m.shares.mockResolvedValue({
shares: [
{ id: 2, username: "bob", name: "Bob", sharedAt: "" },
{ id: 9, username: "dan", name: "Dan", sharedAt: "", disabled: true },
],
canManage: true,
});
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
await settle();
const dan = screen.getByRole("checkbox", { name: /Dan/ });
expect(dan).toHaveAttribute("aria-checked", "true");
expect(dan).toHaveTextContent("(disabled)");
fireEvent.click(dan);
fireEvent.click(screen.getByTestId("workspace-share-save"));
await settle();
expect(m.setShares).toHaveBeenCalledWith(7, [2]);
});
it("takes someone off without asking", async () => {
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
await settle();
fireEvent.click(screen.getByRole("checkbox", { name: /Bob/ }));
fireEvent.click(screen.getByTestId("workspace-share-save"));
await settle();
expect(m.setShares).toHaveBeenCalledWith(7, []);
});
});
describe("WorkspaceRowMenu on a shared workspace", () => {
const open = () =>
render(<WorkspaceRowMenu lane={lane} x={0} y={0} onClose={() => {}} onAction={() => {}} />);
it("offers its owner Share, Rename and Delete", () => {
open();
expect(screen.getByTestId("workspace-menu-share")).toBeInTheDocument();
expect(screen.getByText("Rename")).toBeInTheDocument();
expect(screen.getByText("Delete workspace…")).toBeInTheDocument();
});
it("offers a user it is shared with none of the owner-only items", () => {
perms.me = 2;
open();
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
expect(screen.queryByText("Rename")).toBeNull();
expect(screen.queryByText("Delete workspace…")).toBeNull();
expect(screen.getByText("Copy path")).toBeInTheDocument();
});
it("has no Share in local mode", () => {
perms.local = true;
open();
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
expect(screen.getByText("Rename")).toBeInTheDocument();
});
});
describe("WorkspaceShareModal for one session", () => {
it("reads and saves that session's own list, never the workspace's", async () => {
m.shares.mockClear();
m.setShares.mockClear();
m.sessionShares.mockResolvedValue({ shares: [], canManage: true });
m.setSessionShares.mockResolvedValue({
shares: [{ id: 3, username: "carol", name: "Carol", sharedAt: "" }],
});
render(
<WorkspaceShareModal
lane={lane}
session={{ id: "s-1", name: "Fix cart" }}
onClose={() => {}}
/>
);
await settle();
expect(m.sessionShares).toHaveBeenCalledWith(7, "s-1");
expect(m.shares).not.toHaveBeenCalled();
expect(screen.getByText("Share session")).toBeTruthy();
expect(screen.getByText("Fix cart")).toBeTruthy();
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await settle();
await act(async () => settleConfirm(true));
await settle();
expect(m.setSessionShares).toHaveBeenCalledWith(7, "s-1", [3]);
expect(m.setShares).not.toHaveBeenCalled();
});
});
+15 -42
View File
@@ -1,8 +1,8 @@
/**
* @file RemoteDevices.tsx
* @description The hub's view of remote clients (server mode, server/hub/):
* a user's enrolled machines with Revoke (the viewer's own password again —
* the server checks it and writes the audit entry), and their Company-account
* a user's enrolled machines with Revoke (asked once; the server checks
* who may and writes the audit entry), and their Company-account
* sessions grouped device › folder with what the gateway counted. Shown in
* Settings for the signed-in user's own machines and on the Users page's user
* detail for an admin/analyst (`userId`).
@@ -16,7 +16,6 @@ import { Link } from "react-router-dom";
import { api, type RemoteDevice, type RemoteSessionRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { ConfirmModal } from "../ConfirmModal";
const fmt = (n: number) =>
@@ -44,7 +43,8 @@ export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevok
const groups = useMemo(() => {
const byDevice = new Map<string, Map<string, RemoteSessionRow[]>>();
for (const s of sessions) {
const d = s.deviceName;
// Devices carry no machine name any more ("device"): the id tells them apart.
const d = `${s.deviceName} #${s.deviceId}`;
if (!byDevice.has(d)) byDevice.set(d, new Map());
const folders = byDevice.get(d)!;
const f = s.cwd || "—";
@@ -64,7 +64,9 @@ export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevok
{devices.map((d) => (
<li key={d.id} className="flex flex-wrap items-center gap-2 px-3 py-2 text-xs">
<Laptop className="h-3.5 w-3.5 text-fg-muted" />
<span className="font-medium text-fg-primary">{d.name}</span>
<span className="font-medium text-fg-primary">
{d.name} #{d.id}
</span>
<span className="text-fg-muted">
{d.revokedAt
? t("companyHub.revokedAt", { time: formatDateTime(d.revokedAt) })
@@ -74,11 +76,7 @@ export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevok
</span>
{d.lastSeenAt && (
<span className="text-fg-muted">
·{" "}
{t("companyHub.lastSeen", {
time: formatDateTime(d.lastSeenAt),
ip: d.lastIp || "?",
})}
· {t("companyHub.lastSeen", { time: formatDateTime(d.lastSeenAt) })}
</span>
)}
{canRevoke && d.active && (
@@ -178,15 +176,14 @@ function RevokeDialog({
onDone: () => void;
}) {
const { t } = useTranslation("settings");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!password || busy) return;
if (busy) return;
setBusy(true);
setError(null);
try {
await api.remoteHub.revoke(device.id, password);
await api.remoteHub.revoke(device.id);
toast.success(t("companyHub.revoked", { name: device.name }));
onDone();
} catch (e) {
@@ -203,38 +200,14 @@ function RevokeDialog({
confirmLabel={t("companyHub.revoke")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!password}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-2"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">
{t("companyHub.yourPassword")}
</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
aria-label={t("companyHub.yourPassword")}
/>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
{error && (
<p role="alert" className="mt-3 text-xs text-status-danger">
{error}
</p>
)}
</ConfirmModal>
);
}
@@ -0,0 +1,75 @@
/**
* @file RemotePolicy.tsx
* @description The hub admin's switch for remote clients (server mode,
* `server/hub/remote-policy.js`): whether laptops running AgentHub local may
* connect at all. Off by default — company sessions then run only on the
* hub, and nothing of a user's own machine reaches the hub or Anthropic.
* Flipping it asks once what it does (no password: only an admin reaches
* Administration, and the server checks the role and audits the change).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { Switch } from "../Switch";
export function RemotePolicy() {
const { t } = useTranslation("settings");
const [allowed, setAllowed] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api.remoteHub
.policy()
.then((p) => setAllowed(p.allowDevices))
.catch(() => setAllowed(null));
}, []);
if (allowed === null) return null;
const flip = async (on: boolean) => {
if (busy) return;
const ok = await confirmDialog({
title: on ? t("companyHub.policyOnTitle") : t("companyHub.policyOffTitle"),
message: on ? t("companyHub.policyOnConfirm") : t("companyHub.policyOffConfirm"),
confirmLabel: on ? t("companyHub.policyOnAction") : t("companyHub.policyOffAction"),
destructive: on,
});
if (!ok) return;
setBusy(true);
try {
const p = await api.remoteHub.setPolicy(on);
setAllowed(p.allowDevices);
toast.success(
p.allowDevices ? t("companyHub.policyOnToast") : t("companyHub.policyOffToast")
);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<div
className="mb-4 flex items-start gap-3 rounded-lg border border-border px-3 py-2.5"
data-testid="remote-policy"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-fg-primary">{t("companyHub.policyTitle")}</div>
<p className="mt-0.5 text-xs text-fg-muted">
{allowed ? t("companyHub.policyOnHint") : t("companyHub.policyOffHint")}
</p>
</div>
<Switch
checked={allowed}
disabled={busy}
label={t("companyHub.policyTitle")}
onChange={(v) => void flip(v)}
/>
</div>
);
}
@@ -35,7 +35,7 @@ import {
Share2,
X,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, withToken } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach";
@@ -116,7 +116,7 @@ function useContent(path: string, scope: PreviewScope): Content {
};
const drop = DROP_RE.exec(path);
if (drop) {
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
const src = withToken(`/api/run/drop/${encodeURIComponent(drop[1]!)}`);
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
else if (DROP_TEXT_RE.test(path))
show(
+42 -3
View File
@@ -85,6 +85,8 @@ import {
slotOfKey,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { useAuth } from "../AuthGate";
import { usePanePresence } from "../../hooks/usePanePresence";
type AiToolsState = ReturnType<typeof useAiTools>;
@@ -234,6 +236,19 @@ export function ConsoleTabs({
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const menuRef = useRef<HTMLDivElement>(null);
const tabCtx = useContextMenu<Tab>();
// Server mode: who else has these panes open, and whose money a pane is.
const presence = usePanePresence(lane.id);
const { state: auth } = useAuth();
const meId = auth.mode === "server" ? auth.user?.id : undefined;
const othersIn = (tab: Tab) => {
const seen = new Map<number, { id: number; name: string }>();
for (const r of tab.runs) {
for (const u of presence[r.id] ?? []) if (u.id !== meId) seen.set(u.id, u);
}
return [...seen.values()];
};
const billedTo = (tab: Tab) =>
[...new Set(tab.runs.map((r) => r.startedBy?.name).filter(Boolean))] as string[];
// A group whose runs the list has not caught up with yet is not drawn.
const tabs = useMemo<Tab[]>(
@@ -532,6 +547,7 @@ export function ConsoleTabs({
{tabs.map((tab, i) => {
const active = !settings?.active && tab.index === groups.active;
const single = tab.runs.length === 1 ? tab.focus : null;
const others = othersIn(tab);
const state = tabState(tab);
if (single && renaming === single.id) {
return (
@@ -606,7 +622,7 @@ export function ConsoleTabs({
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
onDoubleClick={() => single && startRename(single)}
title={
title={[
single
? [
toolLabel(single),
@@ -617,8 +633,14 @@ export function ConsoleTabs({
]
.filter(Boolean)
.join("\n")
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
}
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n"),
...billedTo(tab).map((name) => t("tabs.billedTo", { name })),
others.length
? t("tabs.here", { names: others.map((u) => u.name).join(", ") })
: null,
]
.filter(Boolean)
.join("\n")}
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
>
<span className="flex shrink-0 items-center gap-1">
@@ -627,6 +649,23 @@ export function ConsoleTabs({
))}
</span>
<span className="truncate">{tabLabel(tab)}</span>
{others.length > 0 && (
<span
data-testid={`console-tab-people-${tab.key}`}
className="flex shrink-0 -space-x-1"
aria-label={t("tabs.here", { names: others.map((u) => u.name).join(", ") })}
>
{others.slice(0, 3).map(({ id, name }) => (
<span
key={id}
aria-hidden
className="flex h-4 w-4 items-center justify-center rounded-full border border-surface-1 bg-accent-solid text-[9px] font-semibold uppercase text-white"
>
{name.slice(0, 1)}
</span>
))}
</span>
)}
{state && <RunDot state={state} />}
</button>
{tab.runs.some(canSleep) && (
+15 -2
View File
@@ -72,7 +72,7 @@ import { ArrowDown, ArrowUp, X } from "lucide-react";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { api, withToken } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
import {
@@ -634,6 +634,15 @@ export const TerminalView = memo(function TerminalView({
term.input(altArrow, true);
return false;
}
// Shift+Enter: xterm sends a plain CR, so claude/codex SUBMIT the
// prompt. Send Ctrl+J (LF) instead — both prompts take it as a new
// line (Option+Enter is the other way, awkward on a Mac), while a
// shell still runs the line, as Enter did.
if (ev.key === "Enter" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
ev.preventDefault();
term.input("\n", true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -717,7 +726,11 @@ export const TerminalView = memo(function TerminalView({
// own size, so a pane on a background tab is never resized by it.
const size =
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
// The dashboard token, when one is set, rides the query like `/ws`'s:
// the server refuses the upgrade without it.
const sock = new WebSocket(
withToken(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`)
);
ws = sock;
wsRef.current = sock;
// Server sends PTY bytes as binary frames — default binaryType ("blob")
@@ -0,0 +1,79 @@
/**
* @file ConsoleTabs.presence.test.tsx
* @description Workspace sharing phase 4 on the tab bar: a tab shows who ELSE
* has its panes open (never the viewer), and its tooltip says whose money a
* pane a collaborator opened is.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import "../../../i18n";
import type { RunHandle } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { pane, type PaneGroups } from "../../../lib/paneLayout";
import { ConsoleTabs } from "../ConsoleTabs";
vi.mock("../../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
},
}));
vi.mock("../../AuthGate", () => ({
useAuth: () => ({ state: { mode: "server", user: { id: 1, username: "alice" } } }),
usePermissions: () => ({ local: false, isAdmin: false }),
}));
vi.mock("../../../hooks/usePanePresence", () => ({
usePanePresence: () => ({
"agenthub-lane-1": [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
}),
}));
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
const RUN: RunHandle = {
id: "agenthub-lane-1",
laneId: 1,
slot: 0,
status: "running",
cwd: "/w",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
startedBy: { id: 2, name: "Bob" },
tool: "claude",
};
const GROUPS: PaneGroups = { groups: [{ root: pane(0), focus: 0 }], active: 0 };
describe("ConsoleTabs presence", () => {
it("shows who else is in a pane, and whose money it is", () => {
render(
<MemoryRouter>
<ConsoleTabs
lane={LANE}
tabs={[RUN]}
groups={GROUPS}
onGroupsChange={vi.fn()}
onOpen={vi.fn()}
onClosed={vi.fn()}
/>
</MemoryRouter>
);
const people = screen.getAllByTestId(/^console-tab-people-/)[0]!;
expect(people.textContent).toBe("B");
expect(people.getAttribute("aria-label")).toBe("Also here: Bob");
const tab = screen.getAllByRole("tab")[0]!;
expect(tab.getAttribute("title")).toContain("Billed to Bob");
expect(tab.getAttribute("title")).not.toContain("Alice");
});
});
@@ -43,7 +43,10 @@ const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
vi.mock("../../../lib/api", () => ({
vi.mock("../../../lib/api", async () => ({
// The real one: it reads the token from localStorage, as the page does.
withToken: (await vi.importActual<typeof import("../../../lib/api")>("../../../lib/api"))
.withToken,
api: {
run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
@@ -236,6 +239,31 @@ describe("TerminalView", () => {
}
});
it("carries the dashboard token after its size, as /ws does (DASHBOARD_TOKEN)", () => {
// The server refuses a /ws-pty upgrade without it: the terminal sat at
// "reconnecting" forever while the status bar, on /ws, said Live.
localStorage.setItem("dashboard_token", "t ok");
const restore = withBox();
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28&token=t%20ok"
);
} finally {
restore();
}
});
it("carries the token as the only query when it has no size yet", () => {
localStorage.setItem("dashboard_token", "tok");
termState.cols = 0;
termState.rows = 0;
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?token=tok"
);
});
it("a terminal on a hidden tab sends no size until it is shown", () => {
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
// that size resized the real tmux pane under every other client.
@@ -606,6 +634,21 @@ describe("TerminalView", () => {
).toBe(true);
});
it("sends Shift+Enter as Ctrl+J, a new line in claude's prompt, not a submit", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith("\n", true);
inputMock.mockClear();
// Plain Enter and Alt+Enter stay xterm's own.
expect(handler(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(true);
expect(handler(new KeyboardEvent("keydown", { key: "Enter", altKey: true }))).toBe(true);
expect(inputMock).not.toHaveBeenCalled();
});
describe("size reporting (tmux must never be wider than the terminal)", () => {
// A tmux pane wider than xterm wraps each over-long row's tail onto the
// next row's left margin, and tmux never repaints a row it believes
@@ -1,8 +1,8 @@
/**
* @file BackupSettings.tsx
* @description Settings → Data → Backup: the daily database backup of
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
* @description Settings → Data → Backup: the scheduled database backup of
* `server/lib/db-backup.js` — the folder (best on another disk), how often
* (every day … every 30 days), the server's local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
* while it runs, then toasts the outcome) and the newest backup in the folder.
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
* field the environment set is read-only. Restoring is manual and the card says
@@ -12,6 +12,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { AlertTriangle, DatabaseBackup } from "lucide-react";
import { api } from "../../lib/api";
import type { BackupState } from "../../lib/api";
@@ -23,12 +24,24 @@ import { SettingsSection } from "./SettingsRow";
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const pad = (n: number) => String(n).padStart(2, "0");
/** Days between backups offered in the picker (the server takes 1–30). */
const EVERY = [1, 2, 3, 7, 14, 30];
const everyLabel = (t: TFunction<"settings">, n: number) =>
n === 1
? t("backup.everyDay")
: n === 7
? t("backup.everyWeek")
: n === 14
? t("backup.everyTwoWeeks")
: t("backup.everyDays", { count: n });
export function BackupSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<BackupState | null>(null);
const [dir, setDir] = useState("");
const [hour, setHour] = useState(2);
const [keep, setKeep] = useState("7");
const [every, setEvery] = useState(1);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -37,6 +50,7 @@ export function BackupSettings() {
setDir(s.dir);
setHour(s.hour);
setKeep(String(s.keep));
setEvery(s.every ?? 1);
};
useEffect(() => {
@@ -66,11 +80,12 @@ export function BackupSettings() {
const keepN = Number(keep);
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
const patch: { dir?: string; hour?: number; keep?: number } = {};
const patch: { dir?: string; hour?: number; keep?: number; every?: number } = {};
if (state) {
if (dir.trim() !== state.dir) patch.dir = dir.trim();
if (hour !== state.hour) patch.hour = hour;
if (keepN !== state.keep) patch.keep = keepN;
if (every !== (state.every ?? 1)) patch.every = every;
}
const changed = Object.keys(patch).length > 0;
@@ -125,6 +140,19 @@ export function BackupSettings() {
</label>
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="w-40">
<Select
value={String(every)}
onChange={(v) => setEvery(Number(v))}
label={t("backup.every")}
disabled={!state || owned?.every}
searchable={false}
options={(EVERY.includes(every)
? EVERY
: [...EVERY, every].sort((a, b) => a - b)
).map((n) => ({ value: String(n), label: everyLabel(t, n) }))}
/>
</div>
<div className="w-28">
<Select
value={String(hour)}
@@ -192,7 +220,7 @@ export function BackupSettings() {
)}
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
{(owned?.dir || owned?.hour || owned?.keep) && (
{(owned?.dir || owned?.hour || owned?.keep || owned?.every) && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
@@ -1,120 +0,0 @@
/**
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
* mode, every user picks which account of the shared account pool their own
* new sessions run on, per tool, among the accounts their pool group may use
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
* Claude login goes into the user's home at once, so running panes follow it;
* Codex and Cursor take it at the next session. Renders nothing in local mode
* or when the pool holds no account the user may use.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Lock } from "lucide-react";
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
import { toast } from "../../lib/toast";
import { usePermissions } from "../AuthGate";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsRow, SettingsSection } from "./SettingsRow";
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
export function PoolAccountSettings() {
const { t } = useTranslation("settings");
const { local } = usePermissions();
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
const [busy, setBusy] = useState<PoolTool | null>(null);
useEffect(() => {
if (local) return;
api.me
.poolAccounts()
.then((r) => setTools(r.tools))
.catch(() => setTools(null));
}, [local]);
if (local || !tools?.length) return null;
const choose = async (tool: PoolTool, account: string | null) => {
setBusy(tool);
try {
const r = await api.me.choosePoolAccount(tool, account);
setTools((list) =>
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
);
toast.success(
!r.choice
? t("poolAccount.automaticSaved")
: r.switched === "switched" || r.switched === "kept"
? t("poolAccount.switchedNow", { account: r.choice })
: t("poolAccount.nextSession", { account: r.choice })
);
} catch (err) {
toast.error(err);
} finally {
setBusy(null);
}
};
return (
<SettingsSection
id="pool-account"
title={t("poolAccount.title")}
description={t("poolAccount.description")}
>
{tools.map((x) => (
<SettingsRow
key={x.tool}
id={`pool-account-${x.tool}`}
label={
<span className="inline-flex items-center gap-1.5">
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
{LABEL[x.tool]}
</span>
}
description={
x.pin ? (
<span className="inline-flex items-center gap-1">
<Lock className="h-3 w-3" />
{t("poolAccount.pinned", { account: x.pin })}
</span>
) : (
t("poolAccount.count", { count: x.accounts.length })
)
}
disabled={!!x.pin}
control={
<select
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
value={x.pin ?? x.choice ?? ""}
disabled={!!x.pin || busy === x.tool}
onChange={(e) => void choose(x.tool, e.target.value || null)}
className="select !py-1 !text-xs max-w-[16rem]"
>
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
{x.accounts.map((a) => (
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
{[
a.name,
a.email && a.email !== a.name ? `(${a.email})` : null,
a.expired
? `— ${t("poolAccount.expired")}`
: a.full
? `— ${t("poolAccount.full")}`
: a.quotaPercent != null
? `— ${Math.round(a.quotaPercent)}%`
: null,
]
.filter(Boolean)
.join(" ")}
</option>
))}
</select>
}
/>
))}
</SettingsSection>
);
}
@@ -0,0 +1,140 @@
/**
* @file PoolProfiles — the shared account pool's accounts as rows of Settings →
* AI tools → Profiles (server mode). Each account this user's pool group may
* use is one row beside the saved profiles, plus an "Automatic" row; "Switch
* to" picks which account the user's OWN new sessions run on
* (`PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
* Automatic leaves it to the balancer; an admin's pin locks every row. A
* Claude login goes into the user's home at once, so running panes follow it;
* Codex and Cursor take it at the next session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Lock } from "lucide-react";
import { api, type MyPoolTool } from "../../lib/api";
import { toast } from "../../lib/toast";
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
const btnCls =
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
/** Lower-cased emails of a tool's pool accounts — a saved profile with one is the same account. */
export function poolEmails(entry: MyPoolTool | null | undefined): Set<string> {
return new Set(
(entry?.accounts ?? []).map((a) => a.email?.toLowerCase()).filter((e): e is string => !!e)
);
}
export function PoolProfiles({
entry,
onChange,
}: {
entry: MyPoolTool;
onChange: (next: MyPoolTool) => void;
}) {
const { t } = useTranslation("settings");
const { t: tc } = useTranslation("ccConfig");
const [busy, setBusy] = useState<string | null>(null);
const locked = !!entry.pin;
/** The account the user's new sessions run on now; null = Automatic. */
const current = entry.pin ?? entry.choice;
const choose = async (account: string | null) => {
setBusy(account ?? "");
try {
const r = await api.me.choosePoolAccount(entry.tool, account);
onChange({ ...entry, choice: r.choice });
toast.success(
!r.choice
? t("poolAccount.automaticSaved")
: r.switched === "switched" || r.switched === "kept"
? t("poolAccount.switchedNow", { account: r.choice })
: t("poolAccount.nextSession", { account: r.choice })
);
} catch (err) {
toast.error(err);
} finally {
setBusy(null);
}
};
const inUse = (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
{t("poolAccount.inUse")}
</span>
);
const poolBadge = (
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-border bg-surface-3 text-fg-muted">
{t("poolAccount.badge")}
</span>
);
const switchBtn = (account: string | null, disabled = false) => (
<button
type="button"
disabled={busy !== null || disabled}
onClick={() => void choose(account)}
className={btnCls}
>
{busy === (account ?? "") ? tc("profiles.switching") : tc("profiles.switchButton")}
</button>
);
return (
<div className="space-y-3" data-testid={`pool-profiles-${entry.tool}`}>
{locked && (
<p className="text-[11px] text-fg-muted inline-flex items-center gap-1.5">
<Lock className="h-3 w-3" />
{t("poolAccount.pinned", { account: entry.pin })}
</p>
)}
{!locked && (
<div className={rowCls} data-testid={`pool-row-${entry.tool}-automatic`}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-fg-primary">
{t("poolAccount.automatic")}
</span>
{poolBadge}
{current === null && inUse}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{t("poolAccount.automaticHint")}
</span>
{current !== null && switchBtn(null)}
</div>
</div>
)}
{entry.accounts.map((a) => {
const status = a.expired
? t("poolAccount.expired")
: a.full
? t("poolAccount.full")
: a.quotaPercent != null
? t("poolAccount.quota", { pct: Math.round(a.quotaPercent) })
: null;
return (
<div key={a.name} className={rowCls} data-testid={`pool-row-${entry.tool}-${a.name}`}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-fg-primary">{a.name}</span>
{poolBadge}
{current === a.name && inUse}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{a.email && a.email !== a.name ? a.email : ""}
</span>
{!locked && current !== a.name && switchBtn(a.name, a.expired)}
</div>
{status && (
<div
className={`text-[10px] mt-0.5 ${
a.expired || a.full ? "text-status-danger" : "text-fg-muted"
}`}
>
{status}
</div>
)}
</div>
);
})}
</div>
);
}
@@ -881,3 +881,21 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
</section>
);
}
/** Every vendor's rate table, as the Pricing page shows them (Settings in
* local mode, Administration → Server settings in server mode). */
export function AllPricingSettings() {
const { t } = useTranslation("settings");
return (
<div className="space-y-6">
<PricingSettings
vendor="claude"
allowReset
id="pricing-claude"
heading={t("pricing.vendor.claude")}
/>
<PricingSettings vendor="openai" id="pricing-openai" heading={t("pricing.vendor.openai")} />
<PricingSettings vendor="google" id="pricing-google" heading={t("pricing.vendor.google")} />
</div>
);
}
@@ -4,14 +4,20 @@
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
* warning — this panel used to be a modal on the Claude Code page); Codex and
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
* each Claude/Codex profile to the account pool in Administration → Users,
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
* pool's Add dialog on it), and shows how many accounts the pool holds.
* (Antigravity: an OS-keyring login) says why. In server mode the shared
* account pool's accounts this user may use (`GET /api/me/pool-accounts`) are
* rows of the same list (PoolProfiles: "Switch to" picks the account the
* user's own new sessions run on, plus an Automatic row) — there is no
* separate Pool account section any more. A saved profile holding the same
* account (email) as a pool row is folded into that row. Staff also see the
* host's saved logins and how many accounts the whole pool holds; an admin can
* move a profile into the pool in Administration → Users
* (`?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). A
* developer sees only the pool rows of the tools they may use.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
@@ -33,6 +39,7 @@ import {
type CcProfile,
type CcProfileUsage,
type CcUsageWindow,
type MyPoolTool,
type ToolProfile,
type ToolProfileIdentity,
type ToolProfilesTool,
@@ -53,11 +60,14 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
import { SkeletonRows } from "../config/configPrimitives";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsSection } from "./SettingsRow";
import { PoolProfiles, poolEmails } from "./PoolProfiles";
/** The tools the panel covers, in tab order, with the command that signs one in. */
const TOOLS: { id: string; label: string; login: string }[] = [
/** The tools the panel covers, in tab order, with the command that signs one in.
* `poolOnly`: no saved logins of its own — a tab only while the pool has one. */
const TOOLS: { id: string; label: string; login: string; poolOnly?: boolean }[] = [
{ id: "claude", label: "Claude Code", login: "claude auth login" },
{ id: "codex", label: "Codex", login: "codex login" },
{ id: "cursor", label: "Cursor", login: "", poolOnly: true },
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
{ id: "antigravity", label: "Antigravity", login: "agy" },
];
@@ -72,15 +82,21 @@ export function ToolProfilesSettings() {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const { local, seesAll } = usePermissions();
const [tab, setTab] = useState<string>("claude");
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
const [pool, setPool] = useState<AccountPool | null>(null);
/** Server mode: the pool accounts THIS user may use, per tool. */
const [mine, setMine] = useState<MyPoolTool[] | null>(null);
const mineOf = (tool: string) => mine?.find((x) => x.tool === tool) ?? null;
const shown = TOOLS.filter((x) => {
if (mineOf(x.id)) return true;
// The host's own logins are a staff-only read (a developer would get 403s).
if (!seesAll || x.poolOnly) return false;
const tool = aiTools.byId(x.id);
if (x.id === "claude") return tool?.enabled !== false;
return !!tool && tool.installed && tool.enabled !== false;
});
const [tab, setTab] = useState<string>("claude");
const current = shown.find((x) => x.id === tab) ?? shown[0];
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
const [pool, setPool] = useState<AccountPool | null>(null);
const loadOthers = useCallback(
() =>
@@ -91,9 +107,9 @@ export function ToolProfilesSettings() {
[]
);
useEffect(() => {
void loadOthers();
}, [loadOthers]);
// The pool is a server-mode, staff-only read.
if (seesAll) void loadOthers();
}, [loadOthers, seesAll]);
// The whole pool is a server-mode, staff-only read.
useEffect(() => {
if (local || !seesAll) return;
api.accountPool
@@ -101,10 +117,23 @@ export function ToolProfilesSettings() {
.then(setPool)
.catch(() => setPool(null));
}, [local, seesAll]);
// The accounts this user may run on: server mode, every role.
useEffect(() => {
if (local) return;
api.me
.poolAccounts()
.then((r) => setMine(r.tools))
.catch(() => setMine([]));
}, [local]);
if (!current) return null;
const poolCount = (tool: string) =>
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
const entry = mineOf(current.id);
const setEntry = (next: MyPoolTool) =>
setMine((list) => (list ?? []).map((x) => (x.tool === next.tool ? next : x)));
const poolRows = entry ? <PoolProfiles entry={entry} onChange={setEntry} /> : null;
const merged = poolEmails(entry);
return (
<SettingsSection
@@ -132,11 +161,18 @@ export function ToolProfilesSettings() {
))}
</div>
<div role="tabpanel" className="mt-3 space-y-3">
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
{seesAll && POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
<PoolLine count={poolCount(current.id)!} />
)}
{current.id === "claude" ? (
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} local={local} />
{!seesAll || current.poolOnly ? (
poolRows
) : current.id === "claude" ? (
<ClaudeProfiles
pooled={!local && POOL_TOOLS.has("claude")}
local={local}
merged={merged}
poolRows={poolRows}
/>
) : (
<OtherProfiles
key={current.id}
@@ -146,6 +182,8 @@ export function ToolProfilesSettings() {
state={others?.find((o) => o.tool === current.id) ?? null}
loading={others === null}
pooled={!local && POOL_TOOLS.has(current.id)}
merged={merged}
poolRows={poolRows}
onChanged={loadOthers}
/>
)}
@@ -295,6 +333,8 @@ function OtherProfiles({
state,
loading,
pooled,
merged,
poolRows,
onChanged,
}: {
tool: string;
@@ -303,6 +343,10 @@ function OtherProfiles({
state: ToolProfilesTool | null;
loading: boolean;
pooled: boolean;
/** Emails of the pool rows: a saved login of one is shown as that row. */
merged: Set<string>;
/** The pool accounts this user may use, as rows of this list. */
poolRows: ReactNode;
onChanged: () => Promise<unknown>;
}) {
const { t } = useTranslation("settings");
@@ -320,8 +364,8 @@ function OtherProfiles({
</p>
);
}
const items = state.items;
const activeSaved = items.some((p) => p.active);
const activeSaved = state.items.some((p) => p.active);
const items = state.items.filter((p) => !p.email || !merged.has(p.email.toLowerCase()));
const remove = async (p: ToolProfile) => {
const ok = await confirmDialog({
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
@@ -376,7 +420,7 @@ function OtherProfiles({
</div>
)}
</div>
{items.length === 0 && (
{items.length === 0 && !poolRows && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{tc("profiles.empty")}
</div>
@@ -445,6 +489,7 @@ function OtherProfiles({
)}
</div>
))}
{poolRows}
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
</div>
);
@@ -482,7 +527,19 @@ function ActiveBadge() {
// save button only appears while the live account is unsaved, because one
// account is one profile.
function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {
function ClaudeProfiles({
pooled,
local,
merged,
poolRows,
}: {
pooled: boolean;
local: boolean;
/** Emails of the pool rows: a saved profile of one is shown as that row. */
merged: Set<string>;
/** The pool accounts this user may use, as rows of this list. */
poolRows: ReactNode;
}) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
@@ -538,6 +595,12 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
)
: null;
const shownActive = (p: CcProfile) => (decoy ? p.name === decoy.name : p.active);
/** Listed as its own row: not hidden by this browser, and not a pool account
* (that one is the pool's row — one account, one row). */
const listed = useCallback(
(p: CcProfile) => (reveal || !isHidden(p)) && !(p.email && merged.has(p.email.toLowerCase())),
[reveal, isHidden, merged]
);
const load = useCallback(async () => {
try {
@@ -586,11 +649,11 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
if (!items) return;
for (const p of items) {
if (autoFetched.current.has(p.name)) continue;
if (!reveal && isHidden(p)) continue;
if (!listed(p)) continue;
autoFetched.current.add(p.name);
void checkUsage(p.name);
}
}, [items, checkUsage, isHidden, reveal]);
}, [items, checkUsage, listed]);
const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */
@@ -691,75 +754,73 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
</div>
{items === null && !error && <SkeletonRows n={3} />}
{items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && (
{items !== null && items.filter(listed).length === 0 && !poolRows && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("profiles.empty")}
</div>
)}
{items
?.filter((p) => reveal || !isHidden(p))
.map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
onClickCapture={(e) => toggleRow(e, p)}
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
)
}
/>
{shownActive(p) && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!shownActive(p) && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}`
? t("profiles.switching")
: t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
{items?.filter(listed).map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
onClickCapture={(e) => toggleRow(e, p)}
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
)
}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
{shownActive(p) && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!shownActive(p) && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
{poolRows}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
@@ -1,7 +1,7 @@
/**
* @file BackupSettings.test.tsx
* @description Settings → Data → Backup: a save sends only the fields that
* changed, "Back up now" is off until a folder is saved, and the newest backup
* changed (a weekly schedule is `every: 7`), "Back up now" is off until a folder is saved, and the newest backup
* in the folder is shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,6 +22,7 @@ const off = {
dir: "",
hour: 2,
keep: 7,
every: 1,
running: false,
last: null,
files: [],
@@ -50,6 +51,19 @@ describe("BackupSettings", () => {
await waitFor(() => expect(run).toBeEnabled());
});
it("saves a weekly schedule as every: 7", async () => {
const user = userEvent.setup();
backup.get.mockResolvedValue({ ...off, dir: "/mnt/hdd/b" });
backup.save.mockImplementation((p) =>
Promise.resolve({ ok: true, ...off, dir: "/mnt/hdd/b", ...p })
);
render(<BackupSettings />);
await user.click(await screen.findByRole("button", { name: "How often" }));
await user.click(await screen.findByRole("option", { name: "Every week" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(backup.save).toHaveBeenCalledWith({ every: 7 }));
});
it("shows the newest backup", async () => {
backup.get.mockResolvedValue({
...off,
@@ -3,8 +3,8 @@
* @description Settings → Company hub. Local: the enroll form sends the hub
* address, username and password once and clears the password; enrolled, it
* lists what was sent and never shows a token; Log out asks first. Hub
* (server mode): the user's own machines with Revoke (their password again)
* and who opened their sessions.
* (server mode): the user's own machines with Revoke (one confirm, no
* password) and who opened their sessions.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -25,7 +25,14 @@ vi.mock("../../../lib/api", async (importOriginal) => {
...actual,
api: {
remoteClient: { status: vi.fn(), login: vi.fn(), logout: vi.fn(), upload: vi.fn() },
remoteHub: { devices: vi.fn(), sessions: vi.fn(), revoke: vi.fn(), audit: vi.fn() },
remoteHub: {
devices: vi.fn(),
sessions: vi.fn(),
revoke: vi.fn(),
audit: vi.fn(),
policy: vi.fn(),
setPolicy: vi.fn(),
},
},
};
});
@@ -101,7 +108,7 @@ describe("CompanyHubSettings — local", () => {
});
describe("CompanyHubSettings — hub", () => {
it("shows the user's machines, revokes with their password, and who read their sessions", async () => {
it("shows the user's machines, revokes after a confirm, and who read their sessions", async () => {
perms.local = false;
vi.mocked(api.remoteHub.audit).mockResolvedValue({
entries: [
@@ -131,14 +138,25 @@ describe("CompanyHubSettings — hub", () => {
});
vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] });
vi.mocked(api.remoteHub.revoke).mockResolvedValue({ ok: true } as never);
vi.mocked(api.remoteHub.policy).mockResolvedValue({ allowDevices: false, changedAt: null });
const user = userEvent.setup();
await show();
await waitFor(() => expect(screen.getByText("mia-pc")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("mia-pc #7")).toBeInTheDocument());
expect(screen.getByTestId("company-audit")).toHaveTextContent("Boss");
await user.click(screen.getByRole("button", { name: /^(revoke|thu hồi)$/i }));
await user.type(screen.getByLabelText(/your password|mật khẩu của bạn/i), "my-pass-1");
expect(screen.queryByLabelText(/your password|mật khẩu của bạn/i)).toBeNull();
const buttons = screen.getAllByRole("button", { name: /^(revoke|thu hồi)$/i });
await user.click(buttons[buttons.length - 1]!);
await waitFor(() => expect(api.remoteHub.revoke).toHaveBeenCalledWith(7, "my-pass-1"));
await waitFor(() => expect(api.remoteHub.revoke).toHaveBeenCalledWith(7));
});
it("leaves the laptop switch to Administration (no switch here)", async () => {
perms.local = false;
vi.mocked(api.remoteHub.audit).mockResolvedValue({ entries: [] });
vi.mocked(api.remoteHub.devices).mockResolvedValue({ devices: [] });
vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] });
await show();
await screen.findByTestId("remote-devices");
expect(screen.queryByRole("switch", { name: /laptop/i })).toBeNull();
});
});
@@ -1,85 +0,0 @@
/**
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
* hidden in local mode and with no usable account; one select per tool that
* starts on the user's choice (or Automatic) and saves a pick through
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../../i18n";
import { PoolAccountSettings } from "../PoolAccountSettings";
const perms = vi.hoisted(() => ({ local: false }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
vi.mock("../../../lib/api", () => ({
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
}));
const account = (name: string) => ({
name,
kind: "login",
email: `${name}@x.io`,
expired: false,
quotaPercent: 20,
full: false,
});
async function show() {
render(<PoolAccountSettings />);
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
});
}
beforeEach(() => {
perms.local = false;
m.poolAccounts.mockReset();
m.choosePoolAccount.mockReset();
});
describe("PoolAccountSettings", () => {
it("renders nothing in local mode or with no account to choose", async () => {
perms.local = true;
await show();
expect(m.poolAccounts).not.toHaveBeenCalled();
expect(screen.queryByRole("combobox")).toBeNull();
perms.local = false;
m.poolAccounts.mockResolvedValue({ tools: [] });
await show();
expect(screen.queryByRole("combobox")).toBeNull();
});
it("starts on the user's choice and saves a new pick", async () => {
m.poolAccounts.mockResolvedValue({
tools: [
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
],
});
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
await show();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("two");
await userEvent.selectOptions(select, "one");
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
await userEvent.selectOptions(select, "");
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
});
it("is locked on an admin's pin", async () => {
m.poolAccounts.mockResolvedValue({
tools: [
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
],
});
await show();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.disabled).toBe(true);
expect(select.value).toBe("one");
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
});
});
@@ -4,12 +4,16 @@
* failing account costs the others nothing), and its right-click delete asks
* first and never offers to delete the only copy of the live account; Codex's
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
* switched; and in server mode a profile links into the account pool.
* switched; and in server mode a profile links into the account pool, while
* the pool accounts the user may use are rows of the same list — "Switch to"
* picks one through `PUT /api/me/pool-accounts`, an Automatic row goes back to
* the balancer, an admin's pin locks them, a saved profile of the same email
* is folded into the pool row, and a developer sees only the pool rows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { render, screen, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import "../../../i18n";
@@ -38,6 +42,7 @@ const m = vi.hoisted(() => {
},
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
pool: { list: vi.fn() },
me: { poolAccounts: vi.fn(), choosePoolAccount: vi.fn() },
};
});
vi.mock("../../../lib/api", () => ({
@@ -45,6 +50,7 @@ vi.mock("../../../lib/api", () => ({
ccConfig: m.cc,
toolProfiles: m.tp,
accountPool: m.pool,
me: m.me,
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
},
}));
@@ -70,6 +76,7 @@ beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
perms.local = true;
perms.seesAll = true;
m.cc.profiles.mockResolvedValue({
items: [
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
@@ -106,6 +113,7 @@ beforeEach(() => {
});
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
m.me.poolAccounts.mockResolvedValue({ tools: [] });
});
describe("ToolProfilesSettings — Claude", () => {
@@ -218,3 +226,96 @@ describe("ToolProfilesSettings — account pool", () => {
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
});
});
const poolAccount = (name: string, email: string | null = `${name}@x.io`) => ({
name,
kind: "login",
email,
expired: false,
quotaPercent: 20,
full: false,
});
describe("ToolProfilesSettings — pool accounts as profiles", () => {
beforeEach(() => {
perms.local = false;
m.me.poolAccounts.mockResolvedValue({
tools: [
{
tool: "claude",
pin: null,
choice: "two",
accounts: [poolAccount("one", "d@e.f"), poolAccount("two")],
},
],
});
m.me.choosePoolAccount.mockImplementation(async (tool: string, account: string | null) => ({
tool,
choice: account,
switched: account ? "switched" : null,
}));
});
it("lists the pool accounts beside the saved profiles, one row per account", async () => {
await show();
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("Quota 20%");
expect(screen.getByTestId("pool-row-claude-two")).toHaveTextContent("in use");
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
// "spare" is d@e.f — the pool's "one": shown once, as the pool row.
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
});
it("switches the user's own sessions to a pool account, and back to automatic", async () => {
const user = userEvent.setup();
await show();
const one = screen.getByTestId("pool-row-claude-one");
await user.click(within(one).getByRole("button", { name: "Switch to" }));
await settle();
expect(m.me.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
expect(m.cc.activateProfile).not.toHaveBeenCalled();
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
const auto = screen.getByTestId("pool-row-claude-automatic");
await user.click(within(auto).getByRole("button", { name: "Switch to" }));
await settle();
expect(m.me.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
expect(screen.getByTestId("pool-row-claude-automatic")).toHaveTextContent("in use");
});
it("locks every pool row on an admin's pin", async () => {
m.me.poolAccounts.mockResolvedValue({
tools: [
{
tool: "claude",
pin: "one",
choice: null,
accounts: [poolAccount("one"), poolAccount("two")],
},
],
});
await show();
expect(screen.getByText(/pinned you to one/)).toBeInTheDocument();
expect(screen.queryByTestId("pool-row-claude-automatic")).toBeNull();
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
expect(
within(screen.getByTestId("pool-row-claude-two")).queryByRole("button", { name: "Switch to" })
).toBeNull();
});
it("shows a developer only the pool rows, never the host's logins", async () => {
perms.seesAll = false;
await show();
expect(m.cc.profiles).not.toHaveBeenCalled();
expect(m.tp.list).not.toHaveBeenCalled();
expect(m.pool.list).not.toHaveBeenCalled();
expect(screen.getByTestId("pool-row-claude-one")).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /codex/i })).toBeNull();
});
it("is not shown to a developer with no pool account to use", async () => {
perms.seesAll = false;
m.me.poolAccounts.mockResolvedValue({ tools: [] });
await show();
expect(screen.queryByRole("tablist")).toBeNull();
});
});
+10 -44
View File
@@ -14,7 +14,7 @@
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
* `claude setup-token` token; for Codex a saved Codex profile
* (`?tool=codex`), an OpenAI API key or a pasted `auth.json`; for Cursor a
* Cursor API key — or removes one, each time with their own password again;
* Cursor API key — or removes one (asked first; no password: the server checks the role);
* an analyst only reads. The secret only goes IN: the server never sends it
* back. An account's Edit (pencil) picks the users who run on it
* (`PUT /api/accounts/:name/users`, from a `MemberPicker` filtered by role
@@ -628,7 +628,7 @@ function NoPoolPanel({ id, label }: { id: string; label: string }) {
/**
* One account's Edit: tick the users whose sessions run on it. A user already
* on another account of the tool moves here when ticked; unticking one sends
* them back to the least-used account. Saved in one request, password again.
* them back to the least-used account. Saved in one request.
*/
function EditAccountDialog({
pool,
@@ -646,7 +646,6 @@ function EditAccountDialog({
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -658,7 +657,7 @@ function EditAccountDialog({
}, []);
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
const ready = changed && current.length > 0;
const ready = changed;
const groupOf = (userId: number) =>
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
@@ -667,7 +666,7 @@ function EditAccountDialog({
setBusy(true);
setError(null);
try {
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen]));
} catch (e) {
setError(errorText(t, e));
} finally {
@@ -723,16 +722,6 @@ function EditAccountDialog({
/>
</div>
</div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
@@ -773,7 +762,6 @@ function AddAccountDialog({
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
const [name, setName] = useState("");
const [secret, setSecret] = useState("");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -787,9 +775,9 @@ function AddAccountDialog({
const fromProfile = source === "profile";
// A profile already names itself: its name is the default.
const ready =
current.length > 0 &&
(fromProfile ? profile.length > 0 : name.trim().length > 0 && secret.trim().length > 0);
const ready = fromProfile
? profile.length > 0
: name.trim().length > 0 && secret.trim().length > 0;
const submit = async () => {
if (!ready || busy) return;
@@ -802,7 +790,7 @@ function AddAccountDialog({
? { tool, name: name.trim(), credentials: secret.trim() }
: { tool, name: name.trim(), token: secret.trim() };
try {
const r = await api.accountPool.add(body, current);
const r = await api.accountPool.add(body);
onDone(r.account.name);
} catch (e) {
setError(errorText(t, e));
@@ -914,16 +902,6 @@ function AddAccountDialog({
)}
</Field>
)}
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
@@ -944,16 +922,15 @@ function RemoveAccountDialog({
onDone: () => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!current || busy) return;
if (busy) return;
setBusy(true);
setError(null);
try {
await api.accountPool.remove(row.tool, row.name, current);
await api.accountPool.remove(row.tool, row.name);
onDone();
} catch (e) {
setError(errorText(t, e));
@@ -970,7 +947,6 @@ function RemoveAccountDialog({
confirmLabel={t("pool.removeButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
@@ -983,16 +959,6 @@ function RemoveAccountDialog({
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
+10 -43
View File
@@ -8,19 +8,18 @@
* runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* time asked once; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
@@ -135,21 +134,20 @@ function RestartDialog({
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
if (busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
await api.users.tmuxRestart(one.id);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
const r = await api.users.tmuxRestartIdle();
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
@@ -176,45 +174,14 @@ function RestartDialog({
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</div>
{error && (
<p role="alert" className="mt-3 whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
+171 -229
View File
@@ -11,11 +11,15 @@
* there (its members come along and the emptied pool is deleted on save). A
* column's menu moves an account — or a saved login profile, moved into the
* pool on save — in without dragging; Pick members ticks members by role; a
* pool is renamed in place. Nothing is sent until Save, which asks for the
* admin's own password ONCE and applies every change: profiles moved in
* pool is renamed in place. Every change SAVES ITSELF (a moment after the
* last edit, so a rename is not sent per keystroke): profiles moved in
* (`POST /api/accounts`), new pools created, each changed pool set exactly
* (`PATCH`), merged pools deleted (`DELETE`). The trash on a saved pool
* deletes it on its own.
* (`PATCH`), merged pools deleted (`DELETE`). A new pool starts with a name
* ("Pool 3") so it can be saved at once; a pool whose name was cleared waits.
* While a save runs the board takes no drag; a failed save says why, keeps
* what went through, and is tried again on the next change or by Retry. The
* trash on a saved pool deletes it (asked first). No password: only an admin
* edits, and the server checks the role.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -31,7 +35,6 @@ import {
type PoolTool,
type ServerUserUsage,
} from "../../lib/api";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
@@ -76,7 +79,7 @@ type Draft = {
userIds: number[];
accounts: Record<PoolTool, string[]>;
};
/** A saved pool merged into another: deleted on Save. */
/** A saved pool merged into another: deleted on the next save. */
type Removed = { id: number; name: string; into: string };
/** What is being dragged; `from` = the column key it left (null = shared). */
type Drag =
@@ -119,10 +122,21 @@ export function PoolGroups({
renderAccount: (tool: PoolTool, name: string) => ReactNode;
}) {
const { t } = useTranslation("users");
const [draft, setDraft] = useState<Draft[]>(() => fromPool(pool));
const [removed, setRemoved] = useState<Removed[]>([]);
const [draft, setDraftNow] = useState<Draft[]>(() => fromPool(pool));
const [removed, setRemovedNow] = useState<Removed[]>([]);
// While a save runs the board is frozen: an edit made then would be thrown
// away by the re-read that follows a good save (and a pool still being
// created could be created twice), so every user edit goes through these.
const savingNow = useRef(false);
const saveRun = useRef(0);
const setDraft: typeof setDraftNow = (v) => {
if (!savingNow.current) setDraftNow(v);
};
const setRemoved: typeof setRemovedNow = (v) => {
if (!savingNow.current) setRemovedNow(v);
};
const base = useRef(pool);
// Set by Save: the next pool read replaces the board whatever it holds.
// Set by a save: the next pool read replaces the board whatever it holds.
const saved = useRef(false);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [profiles, setProfiles] = useState<Record<PoolTool, PoolProfile[]>>({
@@ -134,6 +148,8 @@ export function PoolGroups({
const [over, setOver] = useState<string | null>(null);
const [picking, setPicking] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// A failed save, and the board it failed on: retried once the board changes.
const [saveError, setSaveError] = useState<{ message: string; at: string } | null>(null);
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
const seq = useRef(0);
@@ -146,13 +162,18 @@ export function PoolGroups({
saved.current || (removed.length === 0 && sigAll(draft) === sigAll(fromPool(base.current)));
base.current = pool;
saved.current = false;
// The re-read after a good save: the board is the server's again, edits reopen.
if (savingNow.current) {
savingNow.current = false;
setSaving(false);
}
if (untouched) {
setDraft(fromPool(pool));
setRemoved([]);
setDraftNow(fromPool(pool));
setRemovedNow([]);
} else {
const ids = new Set((pool.groups ?? []).map((g) => g.id));
setDraft((ds) => ds.filter((d) => d.id === null || ids.has(d.id)));
setRemoved((rs) => rs.filter((r) => ids.has(r.id)));
setDraftNow((ds) => ds.filter((d) => d.id === null || ids.has(d.id)));
setRemovedNow((rs) => rs.filter((r) => ids.has(r.id)));
}
}, [pool]);
@@ -226,11 +247,22 @@ export function PoolGroups({
seq.current += 1;
return `new${seq.current}`;
};
/** A name no pool has yet ("Pool 3"), so a new pool can be saved at once. */
const freshName = () => {
const taken = new Set([
...draft.map((d) => d.name.trim()),
...(pool.groups ?? []).map((g) => g.name),
]);
let n = draft.length + 1;
while (taken.has(t("pool.board.defaultName", { n }))) n += 1;
return t("pool.board.defaultName", { n });
};
const newPool = (seed?: Partial<Pick<Draft, "userIds" | "accounts">>) => {
const key = nextKey();
const name = freshName();
setDraft((ds) => [
...ds,
{ key, id: null, name: "", userIds: [], accounts: emptyAccounts(), ...seed },
{ key, id: null, name, userIds: [], accounts: emptyAccounts(), ...seed },
]);
return key;
};
@@ -285,7 +317,7 @@ export function PoolGroups({
moveAccount(drag.tool, drag.name, to);
};
const dragProps = (drag: Drag) =>
isAdmin
isAdmin && !saving
? {
draggable: true,
onDragStart: (e: DragEvent) => {
@@ -308,7 +340,7 @@ export function PoolGroups({
? dragging.kind === "user" && dragging.id === drag.id
: dragging.kind === "account" && dragging.tool === drag.tool && dragging.name === drag.name);
const dropZone = (key: string | null) =>
isAdmin
isAdmin && !saving
? {
onDragOver: (e: DragEvent) => {
if (!e.dataTransfer.types.includes(DND)) return;
@@ -325,7 +357,7 @@ export function PoolGroups({
}
: {};
// ── What Save will do ─────────────────────────────────────────────────
// ── What the next save does ───────────────────────────────────────────
const original = new Map(fromPool(pool).map((d) => [d.key, sig(d)]));
const changedGroups = draft.filter((d) => d.id === null || original.get(d.key) !== sig(d));
const pendingProfiles = [
@@ -346,10 +378,49 @@ export function PoolGroups({
])
) as Record<PoolTool, string[]>;
const ungrouped = people.filter((u) => groupNameOf(u.id) === null).map((u) => u.id);
const discard = () => {
setDraft(fromPool(pool));
setRemoved([]);
// ── Auto-save ─────────────────────────────────────────────────────────
const boardSig = `${sigAll(draft)}#${removed.map((r) => r.id).join(",")}`;
const save = async () => {
if (saving) return;
savingNow.current = true;
const run = ++saveRun.current;
setSaving(true);
setSaveError(null);
const r = await applyBoard(draft, changedGroups, pendingProfiles, removed);
if (r.error !== null) {
savingNow.current = false;
setSaving(false);
// Keep what went through, so a retry never repeats it.
setDraftNow(r.work);
setRemovedNow(r.gone);
const message = errorText(t, r.error);
setSaveError({
message,
at: `${sigAll(r.work)}#${r.gone.map((x) => x.id).join(",")}`,
});
toast.error(t("pool.board.saveFailed", { error: message }));
return;
}
// Stay frozen until the re-read lands (it replaces the board); a read
// that never comes must not freeze it for good.
saved.current = true;
onChanged();
window.setTimeout(() => {
if (saveRun.current !== run || !savingNow.current) return;
savingNow.current = false;
setSaving(false);
}, 5000);
};
useEffect(() => {
if (!isAdmin || !dirty || unnamed || saving) return;
// The board a save just failed on is not sent again by itself: Retry, or
// the next change, does it.
if (saveError && saveError.at === boardSig) return;
const timer = window.setTimeout(() => void save(), 600);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, dirty, unnamed, saving, boardSig, saveError]);
const memberChip = (id: number, from: string | null) => {
const u = byId.get(id);
@@ -573,7 +644,10 @@ export function PoolGroups({
{t("pool.groups.description")} {isAdmin && t("pool.board.howTo")}
</p>
<div className="mt-3 grid items-start gap-3 md:grid-cols-2 xl:grid-cols-3">
<div
className={`mt-3 grid items-start gap-3 md:grid-cols-2 xl:grid-cols-3 ${saving ? "pointer-events-none opacity-70" : ""}`}
aria-busy={saving}
>
{column(
null,
<div>
@@ -599,7 +673,8 @@ export function PoolGroups({
<input
className="w-full rounded border border-transparent bg-transparent px-1 -mx-1 text-sm font-semibold text-fg-primary transition-colors hover:border-border focus:border-accent focus:outline-none"
value={d.name}
autoFocus={d.id === null && !d.name}
readOnly={saving}
autoFocus={d.id === null}
onChange={(e) => patch(d.key, (x) => ({ ...x, name: e.target.value }))}
placeholder={t("pool.groups.name")}
aria-label={t("pool.groups.name")}
@@ -634,6 +709,7 @@ export function PoolGroups({
? setDeleting(existing)
: setDraft((ds) => ds.filter((x) => x.key !== d.key))
}
disabled={saving}
title={t("pool.groups.delete", { name })}
aria-label={t("pool.groups.delete", { name })}
className="btn-icon hover:text-status-danger"
@@ -675,30 +751,28 @@ export function PoolGroups({
)}
</div>
{isAdmin && dirty && (
{isAdmin && (saving || saveError || unnamed) && (
<div
className="sticky bottom-3 z-10 mt-3 flex animate-slide-up flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-surface-1/95 px-3 py-2 shadow-xl backdrop-blur"
data-testid="pool-board-save"
className="sticky bottom-3 z-10 mt-3 flex animate-slide-up flex-wrap items-center gap-2 rounded-xl border border-border bg-surface-1/95 px-3 py-2 shadow-xl backdrop-blur"
data-testid="pool-board-status"
role="status"
>
<span className="flex-1 text-xs text-fg-secondary">
{t("pool.board.pending", {
count: changedGroups.length + pendingProfiles.length + removed.length,
})}
{unnamed && (
<span className="ml-2 text-status-warning">{t("pool.board.needsName")}</span>
{saving ? (
t("pool.board.saving")
) : saveError ? (
<span className="text-status-danger">
{t("pool.board.saveFailed", { error: saveError.message })}
</span>
) : (
<span className="text-status-warning">{t("pool.board.needsName")}</span>
)}
</span>
<button type="button" onClick={discard} className="btn-ghost text-xs">
{t("pool.board.discard")}
</button>
<button
type="button"
disabled={unnamed}
onClick={() => setSaving(true)}
className="btn-primary text-xs"
>
{t("pool.save")}
</button>
{saveError && !saving && !unnamed && (
<button type="button" onClick={() => void save()} className="btn-primary text-xs">
{t("pool.board.retry")}
</button>
)}
</div>
)}
@@ -722,26 +796,6 @@ export function PoolGroups({
}}
/>
)}
{saving && (
<SaveBoardDialog
draft={draft}
changed={changedGroups}
profiles={pendingProfiles}
removed={removed}
profileLabel={profileLabel}
onClose={() => setSaving(false)}
onProgress={(d, r) => {
setDraft(d);
setRemoved(r);
}}
onDone={() => {
toast.success(t("pool.board.saved"));
setSaving(false);
saved.current = true;
onChanged();
}}
/>
)}
{deleting && (
<DeleteGroupDialog
group={deleting}
@@ -807,157 +861,64 @@ function PickMembersDialog({
}
/**
* Save: the admin's password once, then every staged change in order —
* profiles moved into the pool, new pools created, changed pools set, merged
* pools deleted. What already went through is written back into the board, so
* a retry after a failure never moves a profile in twice, makes a pool again
* or deletes one twice.
* One save of the board, in order — profiles moved into the pool, new pools
* created, changed pools set, merged pools deleted. Returns the board as far
* as it went (`work`, `gone`) and the error that stopped it (null = all
* done), so a retry never moves a profile in twice, makes a pool again or
* deletes one twice.
*/
function SaveBoardDialog({
draft,
changed,
profiles,
removed,
profileLabel,
onClose,
onProgress,
onDone,
}: {
draft: Draft[];
changed: Draft[];
async function applyBoard(
draft: Draft[],
changed: Draft[],
/** `tool|profile:name` entries to move into the pool first. */
profiles: string[];
profiles: string[],
/** Saved pools merged into another, deleted last. */
removed: Removed[];
profileLabel: (tool: PoolTool, name: string) => string;
onClose: () => void;
onProgress: (d: Draft[], removed: Removed[]) => void;
onDone: () => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
let work = draft.map((d) => ({
...d,
userIds: [...d.userIds],
accounts: { ...d.accounts },
}));
const todo = new Set(changed.map((d) => d.key));
let gone = [...removed];
try {
for (const entry of profiles) {
const [tool, ref] = entry.split("|") as [PoolTool, string];
const profile = ref.slice(PROFILE.length);
const r = await api.accountPool.add({ tool, name: profile, profile }, current);
work = work.map((d) => ({
...d,
accounts: {
...d.accounts,
[tool]: d.accounts[tool].map((n) => (n === ref ? r.account.name : n)),
},
}));
}
for (const d of work) {
if (!todo.has(d.key)) continue;
let id = d.id;
if (id === null) {
id = (await api.accountPool.createGroup(d.name.trim(), current)).group.id;
const made = id;
work = work.map((x) => (x.key === d.key ? { ...x, id: made } : x));
}
await api.accountPool.updateGroup(
id,
{
...(d.id !== null ? { name: d.name.trim() } : {}),
userIds: d.userIds,
accounts: Object.fromEntries(TOOLS.map((tool) => [tool, d.accounts[tool]])),
},
current
);
todo.delete(d.key);
}
// Last: the merged pools' members already moved into their new pool.
for (const r of removed) {
await api.accountPool.deleteGroup(r.id, current);
gone = gone.filter((x) => x.id !== r.id);
}
onDone();
} catch (e) {
onProgress(work, gone);
setError(errorText(t, e));
} finally {
setBusy(false);
removed: Removed[]
): Promise<{ work: Draft[]; gone: Removed[]; error: unknown }> {
let work = draft.map((d) => ({
...d,
userIds: [...d.userIds],
accounts: { ...d.accounts },
}));
const todo = new Set(changed.map((d) => d.key));
let gone = [...removed];
try {
for (const entry of profiles) {
const [tool, ref] = entry.split("|") as [PoolTool, string];
const profile = ref.slice(PROFILE.length);
const r = await api.accountPool.add({ tool, name: profile, profile });
work = work.map((d) => ({
...d,
accounts: {
...d.accounts,
[tool]: d.accounts[tool].map((n) => (n === ref ? r.account.name : n)),
},
}));
}
};
return (
<ConfirmModal
open
title={t("pool.board.saveTitle")}
message={t("pool.board.saveHint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<ul className="mt-3 space-y-1 text-xs text-fg-secondary" data-testid="pool-board-summary">
{profiles.map((entry) => {
const [tool, ref] = entry.split("|") as [PoolTool, string];
return (
<li key={entry}>
{t("pool.board.summaryProfile", {
name: profileLabel(tool, ref.slice(PROFILE.length)),
})}
</li>
);
})}
{removed.map((r) => (
<li key={`m${r.id}`}>{t("pool.board.summaryMerge", { from: r.name, into: r.into })}</li>
))}
{changed.map((d) => (
<li key={d.key}>
{t(d.id === null ? "pool.board.summaryNew" : "pool.board.summaryGroup", {
name: d.name.trim(),
count: d.userIds.length,
accounts: TOOLS.reduce((n, tool) => n + d.accounts[tool].length, 0),
})}
</li>
))}
</ul>
<label className="mt-3 block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
</label>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</ConfirmModal>
);
for (const d of work) {
if (!todo.has(d.key)) continue;
let id = d.id;
if (id === null) {
id = (await api.accountPool.createGroup(d.name.trim())).group.id;
const made = id;
work = work.map((x) => (x.key === d.key ? { ...x, id: made } : x));
}
await api.accountPool.updateGroup(id, {
...(d.id !== null ? { name: d.name.trim() } : {}),
userIds: d.userIds,
accounts: Object.fromEntries(TOOLS.map((tool) => [tool, d.accounts[tool]])),
});
todo.delete(d.key);
}
// Last: the merged pools' members already moved into their new pool.
for (const r of removed) {
await api.accountPool.deleteGroup(r.id);
gone = gone.filter((x) => x.id !== r.id);
}
return { work, gone, error: null };
} catch (e) {
return { work, gone, error: e };
}
}
function DeleteGroupDialog({
@@ -970,15 +931,14 @@ function DeleteGroupDialog({
onDone: () => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!current || busy) return;
if (busy) return;
setBusy(true);
setError(null);
try {
await api.accountPool.deleteGroup(group.id, current);
await api.accountPool.deleteGroup(group.id);
onDone();
} catch (e) {
setError(errorText(t, e));
@@ -994,27 +954,9 @@ function DeleteGroupDialog({
confirmLabel={t("pool.removeButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<label className="mt-3 block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</label>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
+6 -22
View File
@@ -10,8 +10,7 @@
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), and
* "sign out everywhere".
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
* the server validates the whole change before it applies any of it. A
* what changed — the server validates the whole change before it applies any of it. A
* refusal is shown inside the dialog, which stays open.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -109,7 +108,6 @@ export function UserEditDialog({
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
);
const quotaLabel = useQuotaLabel();
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -141,7 +139,8 @@ export function UserEditDialog({
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
if (!self && role !== user.role) patch.role = role;
if (!self && active === user.disabled) patch.disabled = !active;
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
if (role === "admin" && hostAccount !== Boolean(user.hostAccount))
patch.hostAccount = hostAccount;
if (password) patch.password = password;
if (added.length) patch.addWorkspaces = added;
if (removed.size) patch.removeWorkspaces = [...removed];
@@ -172,14 +171,14 @@ export function UserEditDialog({
// choose their own at next sign-in); your own must pass every rule.
const weakOwn =
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
const ready = changed && current.length > 0 && !weakOwn && !quotaBad && !busy;
const ready = changed && !weakOwn && !quotaBad && !busy;
const submit = async () => {
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.users.update(user.id, patch, current);
await api.users.update(user.id, patch);
onDone(t("done.updated", { name: user.displayName || user.username }));
} catch (e) {
const err = e as Error & { code?: string };
@@ -196,10 +195,6 @@ export function UserEditDialog({
/** Delete the user for good — asked first, with what goes, in red. */
const remove = async () => {
if (self || busy) return;
if (!current) {
setError(t("delete.needsPassword"));
return;
}
const name = user.displayName || user.username;
const ok = await confirmDialog({
title: t("delete.title", { name }),
@@ -211,7 +206,7 @@ export function UserEditDialog({
setBusy(true);
setError(null);
try {
await api.users.remove(user.id, current);
await api.users.remove(user.id);
onDone(t("done.deleted", { name }));
} catch (e) {
const err = e as Error & { code?: string };
@@ -593,17 +588,6 @@ export function UserEditDialog({
</div>
<div className="space-y-3 border-t border-border bg-surface-2/40 px-5 py-4">
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
onKeyDown={onKeyDown}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
+4 -18
View File
@@ -5,7 +5,7 @@
* line with its bar — red once used up, with when it opens again;
* `QuotaFields`, a limit in dollars and its period (a day from local midnight,
* or a sliding 5 hours); and `QuotaDefaultCard`, the default every user
* without one of their own gets — an admin edits it with their own password
* without one of their own gets — an admin edits it
* again (`PUT /api/users/quota-default`), an analyst only reads. A user's own
* setting is part of the Edit user form (`UserEditDialog`). A user over their
* quota can start no session and their prompts are refused until it resets.
@@ -120,7 +120,7 @@ export function parseLimit(text: string): number | null {
return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
}
/** The default quota: shown to staff, edited by an admin with their own password. */
/** The default quota: shown to staff, edited by an admin. */
export function QuotaDefaultCard({
quotaDefault,
isAdmin,
@@ -182,11 +182,10 @@ function QuotaDefaultDialog({
const [on, setOn] = useState(current !== null);
const [limit, setLimit] = useState(current ? String(current.limit_usd) : "");
const [period, setPeriod] = useState<QuotaPeriod>(current?.period ?? "day");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const value = parseLimit(limit);
const ready = password.length > 0 && (!on || value !== null) && !busy;
const ready = (!on || value !== null) && !busy;
const submit = async () => {
if (!ready) return;
@@ -196,8 +195,7 @@ function QuotaDefaultDialog({
await api.users.setQuotaDefault(
on && value !== null
? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() }
: null,
password
: null
);
onDone(t("quota.defaultSaved"));
} catch (e) {
@@ -246,18 +244,6 @@ function QuotaDefaultDialog({
disabled={!on}
/>
<p className="text-[11px] text-fg-muted">{t("quota.enforceHint")}</p>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onKeyDown}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
@@ -4,16 +4,18 @@
* tool's accounts (live panes and quota) and members per pool, then a tab per
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
* other installed tool; an
* admin adds an account only with their own password typed (the token goes
* admin adds an account with no password of their own asked (the token goes
* in once and is not shown back) — a saved login moved in (the server's own
* included), or a setup-token — and removes one the same way, and an analyst
* sees no add or remove control. An account's Edit ticks the users who run on
* it, saved with the admin's own password; the users show on the account.
* The board moves members and accounts between pools (drag, or a column's
* menu) — an account sits in one pool, so a move takes it out of the other —
* splits an account off onto New pool, merges a pool by dragging its only
* included), or a setup-token — and removes one after a confirm, and an
* analyst sees no add or remove control. An account's Edit ticks the users
* who run on it; the users show on the account. The board moves members and
* accounts between pools (drag, or a column's menu) — an account sits in one
* pool, so a move takes it out of the other — splits an account off onto New
* pool (named "Pool <n>" until renamed), merges a pool by dragging its only
* account into another, adds a saved profile to a pool, shows each account's
* plan, and saves every change with ONE password.
* plan, and saves every change by itself a moment after the last edit — a
* failed save says why and is sent again only by Retry, never twice over;
* the board is frozen while a save runs, so no edit made then is lost.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -156,6 +158,16 @@ function drag(from: Element, to: Element) {
fireEvent.drop(to, { dataTransfer });
}
/**
* The pool the server answers with, a fresh object on every read — as a real
* re-read after a save is — so the board takes it as new.
*/
const serve = (pool: AccountPool) =>
accountPool.list.mockImplementation(async () => structuredClone(pool));
/** The board saves itself ~600 ms after the last edit. */
const SAVED = { timeout: 3000 };
beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset();
aiTools.list.mockResolvedValue({
@@ -242,21 +254,23 @@ describe("AccountPoolCard", () => {
expect(screen.queryByLabelText("From")).toBeNull();
await user.type(screen.getByLabelText("Name"), "cu-1");
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
await user.type(screen.getByLabelText("Your password"), "boss-pass");
expect(screen.queryByLabelText("Your password")).toBeNull();
await user.click(
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "cursor", name: "cu-1", token: "key_abc" },
"boss-pass"
)
expect(accountPool.add).toHaveBeenCalledWith({
tool: "cursor",
name: "cu-1",
token: "key_abc",
})
);
expect(accountPool.add.mock.calls[0]).toHaveLength(1);
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
});
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
accountPool.list.mockResolvedValue({
it("builds pools on the board: members, accounts and profiles moved in, saved by itself", async () => {
serve({
...POOL,
groups: [
{
@@ -276,45 +290,45 @@ describe("AccountPoolCard", () => {
expect(teamA).toHaveTextContent("leo");
expect(teamA).toHaveTextContent("team-1");
await screen.findByText("mia");
expect(screen.queryByTestId("pool-board-save")).toBeNull(); // no Save any more
// The edits land at once (fireEvent), well inside the save's 600 ms wait.
await user.click(screen.getByRole("button", { name: "New pool" }));
const names = screen.getAllByLabelText("Pool name");
await user.type(names[names.length - 1]!, "Team B");
const names = screen.getAllByLabelText("Pool name") as HTMLInputElement[];
const name = names[names.length - 1]!;
expect(name.value).toBe("Pool 2"); // a new pool starts with a name
fireEvent.change(name, { target: { value: "Team B" } });
const teamB = screen.getAllByTestId("pool-group")[1]!;
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
expect(teamB).toHaveTextContent("mia");
const add = within(teamB).getByLabelText("Move an account into Team B");
await user.selectOptions(add, "codex|cx-1");
await user.selectOptions(add, "claude|profile:work");
fireEvent.change(add, { target: { value: "codex|cx-1" } });
fireEvent.change(add, { target: { value: "claude|profile:work" } });
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
expect(accountPool.updateGroup).not.toHaveBeenCalled();
expect(accountPool.updateGroup).not.toHaveBeenCalled(); // waits for the last edit
await user.click(
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
await waitFor(
() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(2, {
userIds: [7],
accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] },
}),
SAVED
);
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
await user.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(
2,
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
"boss-pass"
)
);
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "claude", name: "work", profile: "work" },
"boss-pass"
);
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
expect(accountPool.add).toHaveBeenCalledWith({ tool: "claude", name: "work", profile: "work" });
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B");
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
// Team A did not change: nothing is sent for it.
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
// The profile moved in first, then the pool was made, then filled.
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
fn.mock.invocationCallOrder[0]!;
expect(order(accountPool.add)).toBeLessThan(order(accountPool.createGroup));
expect(order(accountPool.createGroup)).toBeLessThan(order(accountPool.updateGroup));
});
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
accountPool.list.mockResolvedValue({
serve({
...POOL,
groups: [
{
@@ -326,7 +340,6 @@ describe("AccountPoolCard", () => {
],
});
accountPool.updateGroup.mockResolvedValue({ group: {} });
const user = userEvent.setup();
render(card(true));
const teamA = await screen.findByTestId("pool-group");
const shared = screen.getByTestId("pool-shared");
@@ -341,27 +354,88 @@ describe("AccountPoolCard", () => {
expect(held(teamA)).toEqual(["team-2"]);
expect(held(shared)).toContain("team-1");
expect(teamA).toHaveTextContent("team-2");
await user.click(
fireEvent.click(
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
);
expect(shared).toHaveTextContent("leo");
await user.click(
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
);
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
await user.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(
1,
{
await waitFor(
() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(1, {
name: "Team A",
userIds: [],
accounts: { claude: ["team-2"], codex: [], cursor: [] },
},
"boss-pass"
)
}),
SAVED
);
// The three edits went out as ONE save.
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
expect(accountPool.createGroup).not.toHaveBeenCalled();
expect(accountPool.deleteGroup).not.toHaveBeenCalled();
});
it("says why a save failed, keeps what went through, and sends again only on Retry", async () => {
serve(POOL);
accountPool.createGroup.mockResolvedValue({ group: { id: 4, name: "Pool 1" } });
accountPool.updateGroup
.mockRejectedValueOnce(new Error("disk full"))
.mockResolvedValue({ group: {} });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
drag(
within(screen.getByTestId("pool-shared"))
.getAllByTestId("pool-group-account")
.find((c) => c.textContent?.includes("team-2"))!,
screen.getByTestId("pool-new")
);
const status = await screen.findByText(/Not saved: disk full/, undefined, SAVED);
expect(status.closest("[data-testid=pool-board-status]")).toBeInTheDocument();
// The failed board is not re-sent by itself.
await new Promise((r) => setTimeout(r, 900));
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(accountPool.updateGroup).toHaveBeenCalledTimes(2), SAVED);
// The pool was made once: the retry fills the one already created.
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
expect(accountPool.createGroup).toHaveBeenCalledWith("Pool 1");
expect(accountPool.updateGroup).toHaveBeenLastCalledWith(4, {
name: "Pool 1",
userIds: [],
accounts: { claude: ["team-2"], codex: [], cursor: [] },
});
});
it("freezes the board while a save runs, so no edit made then is lost", async () => {
serve(POOL);
let finish: (v: unknown) => void = () => {};
accountPool.createGroup.mockImplementation(
() => new Promise((r) => (finish = () => r({ group: { id: 4, name: "Pool 1" } })))
);
accountPool.updateGroup.mockResolvedValue({ group: {} });
render(card(true));
await screen.findByText("team-1");
drag(
within(screen.getByTestId("pool-shared"))
.getAllByTestId("pool-group-account")
.find((c) => c.textContent?.includes("team-2"))!,
screen.getByTestId("pool-new")
);
await waitFor(() => expect(accountPool.createGroup).toHaveBeenCalledTimes(1), SAVED);
const name = screen.getByDisplayValue("Pool 1");
expect(screen.getByTestId("pool-shared").closest("[aria-busy]")).toHaveAttribute(
"aria-busy",
"true"
);
// A rename typed mid-save is refused, not queued behind the save.
fireEvent.change(name, { target: { value: "Renamed" } });
expect(name).toHaveValue("Pool 1");
finish(null);
await waitFor(() => expect(accountPool.updateGroup).toHaveBeenCalledTimes(1), SAVED);
expect(accountPool.updateGroup).toHaveBeenCalledWith(4, {
userIds: [],
accounts: { claude: ["team-2"], codex: [], cursor: [] },
});
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
});
it("shows each account's plan on its card", async () => {
@@ -376,7 +450,7 @@ describe("AccountPoolCard", () => {
});
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
accountPool.list.mockResolvedValue({
serve({
...POOL,
groups: [
{
@@ -396,7 +470,6 @@ describe("AccountPoolCard", () => {
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
accountPool.updateGroup.mockResolvedValue({ group: {} });
accountPool.deleteGroup.mockResolvedValue({ ok: true });
const user = userEvent.setup();
render(card(true));
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
await screen.findByText("mia");
@@ -409,40 +482,32 @@ describe("AccountPoolCard", () => {
const pools = screen.getAllByTestId("pool-group");
expect(pools).toHaveLength(3);
expect(held(pools[2]!)).toEqual(["team-2"]);
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
const name = within(pools[2]!).getByLabelText("Pool name") as HTMLInputElement;
expect(name.value).toBe("Pool 3");
fireEvent.change(name, { target: { value: "Solo" } });
// Merge: Team B's only account into Team A brings mia along; Team B goes.
drag(item(teamB!, "cx-1"), teamA!);
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
expect(teamA).toHaveTextContent("mia");
await user.click(
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
);
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
await user.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
expect(accountPool.updateGroup).toHaveBeenCalledWith(
1,
{
name: "Team A",
userIds: [8, 7],
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
},
"boss-pass"
);
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
expect(accountPool.updateGroup).toHaveBeenCalledWith(
3,
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
"boss-pass"
);
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2), SAVED);
expect(accountPool.deleteGroup).toHaveBeenCalledTimes(1);
expect(accountPool.updateGroup).toHaveBeenCalledWith(1, {
name: "Team A",
userIds: [8, 7],
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
});
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo");
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
expect(accountPool.updateGroup).toHaveBeenCalledWith(3, {
userIds: [],
accounts: { claude: ["team-2"], codex: [], cursor: [] },
});
// The members moved before the emptied pool was deleted.
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
fn.mock.invocationCallOrder[0]!;
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
const last = (fn: { mock: { invocationCallOrder: number[] } }) =>
Math.max(...fn.mock.invocationCallOrder);
expect(last(accountPool.updateGroup)).toBeLessThan(last(accountPool.deleteGroup));
});
it("turns Oh My Pi login sharing on from its tab", async () => {
@@ -470,18 +535,18 @@ describe("AccountPoolCard", () => {
const confirm = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Add account"
)!;
expect(confirm).toBeDisabled();
await user.type(screen.getByLabelText("Your password"), "boss-pass");
expect(confirm).toBeEnabled();
await user.click(confirm);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "claude", name: "work", profile: "work" },
"boss-pass"
)
expect(accountPool.add).toHaveBeenCalledWith({
tool: "claude",
name: "work",
profile: "work",
})
);
});
it("adds a setup-token only with the admin's own password", async () => {
it("adds a setup-token once a name and token are typed, with no password", async () => {
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
const user = userEvent.setup();
render(card(true));
@@ -489,37 +554,35 @@ describe("AccountPoolCard", () => {
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
const dialog = await screen.findByRole("dialog");
await user.selectOptions(screen.getByLabelText("From"), "token");
await user.type(screen.getByLabelText("Name"), "team-2");
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
const confirm = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Add account"
)!;
expect(confirm).toBeDisabled();
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.type(screen.getByLabelText("Name"), "team-2");
expect(confirm).toBeDisabled(); // no token yet
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
expect(confirm).toBeEnabled();
await user.click(confirm);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "claude", name: "team-2", token: "sk-ant-oat01-xyz" },
"boss-pass"
)
expect(accountPool.add).toHaveBeenCalledWith({
tool: "claude",
name: "team-2",
token: "sk-ant-oat01-xyz",
})
);
});
it("removes an account with the admin's own password", async () => {
it("removes an account after a confirm, with no password", async () => {
accountPool.remove.mockResolvedValue({ ok: true });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
await user.click(screen.getByRole("button", { name: "Remove" }));
await waitFor(() =>
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
);
expect(accountPool.remove).not.toHaveBeenCalled();
await user.click(await screen.findByRole("button", { name: "Remove" }));
await waitFor(() => expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1"));
});
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
it("sets who runs on an account from its Edit, with no password", async () => {
accountPool.list.mockResolvedValue({
...POOL,
pins: {
@@ -550,12 +613,9 @@ describe("AccountPoolCard", () => {
const save = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Save"
)!;
expect(save).toBeDisabled();
await user.type(screen.getByLabelText("Your password"), "boss-pass");
expect(save).toBeEnabled();
await user.click(save);
await waitFor(() =>
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
);
await waitFor(() => expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7]));
});
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
@@ -574,16 +634,12 @@ describe("AccountPoolCard", () => {
const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
const confirm = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Add account"
)!;
await user.click(confirm);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "codex", name: "cx", profile: "cx" },
"boss-pass"
)
expect(accountPool.add).toHaveBeenCalledWith({ tool: "codex", name: "cx", profile: "cx" })
);
});
@@ -5,8 +5,8 @@
* user per account, names the installed tmux and warns until tmux-member is
* installed; a row's cell shows the version with a badge when it is older than
* the installed tmux, and "—" for a member with no server; an admin restarts
* one member — or every idle outdated one, the working member left out — only
* with their own password typed; an analyst gets no "Restart idle".
* one member — or every idle outdated one, the working member left out — after
* one confirm (no password); an analyst gets no "Restart idle".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -112,16 +112,15 @@ describe("MemberTmux", () => {
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
});
it("restarts one member only with the admin's password", async () => {
it("restarts one member after a confirm, with no password", async () => {
users.tmuxRestart.mockResolvedValue({});
render(<Harness isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
const go = screen.getByRole("button", { name: "Restart" });
expect(go).toBeDisabled();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
await userEvent.click(go);
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2, "pw"));
expect(screen.queryByLabelText("Your password")).toBeNull();
expect(users.tmuxRestart).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2));
});
it("restarts every idle outdated member at once, the working one left out", async () => {
@@ -129,8 +128,7 @@ describe("MemberTmux", () => {
render(<Harness isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith("pw"));
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith());
});
});
@@ -1,10 +1,10 @@
/**
* @file UserEditDialog.test.tsx
* @description The admin's account editor: Save stays off until something
* changed AND the admin typed their own password; one PATCH carries only the
* changed fields (profile, role, disable, workspace add/remove, sign-out); an
* admin editing their own row cannot touch role or state; Generate fills a
* 16-character password.
* changed (no password of the admin's own is asked); one PATCH carries only
* the changed fields (profile, role, disable, workspace add/remove, sign-out);
* an admin editing their own row cannot touch role or state; Delete asks a red
* confirm first; Generate fills a 16-character password.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -72,12 +72,11 @@ beforeEach(() => {
});
describe("UserEditDialog", () => {
it("needs a change and the admin's own password before Save", async () => {
it("needs a change before Save, and asks for no password", async () => {
const ue = userEvent.setup();
open();
await screen.findByText("api");
expect(save().hasAttribute("disabled")).toBe(true);
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
expect(screen.queryByLabelText("Your password")).toBeNull();
expect(save().hasAttribute("disabled")).toBe(true); // nothing changed yet
await ue.type(screen.getByLabelText("Display name"), "Mia");
expect(save().hasAttribute("disabled")).toBe(false);
@@ -94,22 +93,18 @@ describe("UserEditDialog", () => {
await ue.selectOptions(screen.getByLabelText("Add workspace"), "7");
await ue.click(screen.getByRole("button", { name: "Add workspace" }));
await ue.click(screen.getByRole("checkbox", { name: "Sign out everywhere" }));
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
await ue.click(save());
await waitFor(() =>
expect(users.update).toHaveBeenCalledWith(
2,
{
email: "mia@x.vn",
role: "analyst",
disabled: true,
addWorkspaces: [7],
removeWorkspaces: [5],
signOut: true,
},
"boss-pass"
)
expect(users.update).toHaveBeenCalledWith(2, {
email: "mia@x.vn",
role: "analyst",
disabled: true,
addWorkspaces: [7],
removeWorkspaces: [5],
signOut: true,
})
);
expect(users.update.mock.calls[0]).toHaveLength(2);
expect(onDone).toHaveBeenCalledWith("mia updated");
});
@@ -125,21 +120,15 @@ describe("UserEditDialog", () => {
open();
await screen.findByText("api");
await ue.type(screen.getByLabelText("New password"), "abc");
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
expect(save().hasAttribute("disabled")).toBe(false);
});
it("deletes a user after the admin's password and a red confirm that says what goes", async () => {
it("deletes a user after a red confirm that says what goes, with no password", async () => {
users.remove.mockReset().mockResolvedValue({ user: mia });
confirmDialog.mockReset().mockResolvedValue(true);
const ue = userEvent.setup();
const { onDone } = open();
await screen.findByText("api");
// No password yet: asked for it, nothing sent.
await ue.click(screen.getByRole("button", { name: "Delete user" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Enter your own password");
expect(users.remove).not.toHaveBeenCalled();
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
await ue.click(screen.getByRole("button", { name: "Delete user" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
@@ -147,18 +136,18 @@ describe("UserEditDialog", () => {
message: expect.stringContaining("/srv/agenthub/users/mia"),
})
);
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2, "boss-pass"));
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2));
expect(onDone).toHaveBeenCalled();
});
it("sends nothing when the confirm is cancelled, and offers no delete for yourself", async () => {
it("sends nothing when the delete confirm is cancelled", async () => {
users.remove.mockReset();
confirmDialog.mockReset().mockResolvedValue(false);
const ue = userEvent.setup();
open();
await screen.findByText("api");
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
await ue.click(screen.getByRole("button", { name: "Delete user" }));
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
expect(users.remove).not.toHaveBeenCalled();
});
@@ -2,7 +2,7 @@
* @file UserQuota.test.tsx
* @description Spend quotas on the Users page: a row's "spent of limit" line
* (red once used up, "(default)" when it is the default), the default quota
* card (an admin edits it with their own password, an analyst only reads),
* card (an admin edits it with no password asked, an analyst only reads),
* and the Edit user form sending a user's own quota as part of its ONE PATCH.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -67,7 +67,7 @@ describe("QuotaSummary", () => {
});
describe("QuotaDefaultCard", () => {
it("an admin sets the default with their own password", async () => {
it("an admin sets the default, with no password asked", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(<QuotaDefaultCard quotaDefault={null} isAdmin onSaved={onSaved} />);
@@ -77,14 +77,13 @@ describe("QuotaDefaultCard", () => {
await user.type(screen.getByLabelText("Limit in dollars"), "25");
await user.selectOptions(screen.getByLabelText("Period"), "5h");
const save = screen.getByRole("button", { name: "Save" });
expect(save).toBeDisabled(); // no password yet
await user.type(screen.getByLabelText("Your password"), "pw");
expect(screen.queryByLabelText("Your password")).toBeNull();
await user.click(save);
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(users.setQuotaDefault).toHaveBeenCalledWith(
expect.objectContaining({ limit_usd: 25, period: "5h" }),
"pw"
expect.objectContaining({ limit_usd: 25, period: "5h" })
);
expect(users.setQuotaDefault.mock.calls[0]).toHaveLength(1);
});
it("an analyst only reads it", () => {
@@ -131,14 +130,11 @@ describe("UserEditDialog quota", () => {
expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument();
await user.selectOptions(select, "custom");
await user.type(screen.getByLabelText("Limit in dollars"), "40");
await user.type(screen.getByLabelText("Your password"), "pw");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
expect(users.update).toHaveBeenCalledWith(
7,
{ quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) },
"pw"
);
expect(users.update).toHaveBeenCalledWith(7, {
quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }),
});
});
it("No limit is a change; an empty custom limit keeps Save off", async () => {
@@ -146,13 +142,12 @@ describe("UserEditDialog quota", () => {
render(
<UserEditDialog user={mia} self={false} users={[mia]} onClose={() => {}} onDone={() => {}} />
);
await user.type(screen.getByLabelText("Your password"), "pw");
await user.selectOptions(screen.getByLabelText("Spend quota"), "custom");
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw")
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } })
);
});
});
+5 -1
View File
@@ -53,12 +53,16 @@ function load(): Promise<void> {
}
function onMessage(msg: WSMessage) {
// A workspace shared with this user, or taken back: re-read the list.
if (msg.type === "lane_access") return void load();
if (msg.type !== "lane_update") return;
const payload = msg.data as { lane?: Lane; removed?: number };
const lane = payload.lane;
if (payload.removed !== undefined || !lane || !lanes) return void load();
const i = lanes.findIndex((l) => l.id === lane.id);
if (i === -1) return void load();
// A broadcast carries no viewer's `access`: a lane seen through a shared
// session re-reads it (its access may have changed too).
if (i === -1 || lanes[i]?.access) return void load();
const next = [...lanes];
next[i] = lane;
publish(next);
+54
View File
@@ -0,0 +1,54 @@
/**
* @file usePanePresence.ts
* @description Who has each of a workspace's panes open (server mode, workspace
* sharing phase 4): `GET /api/lanes/:id/presence` once, then every
* `pane_presence` frame for that workspace, and a re-read on a WebSocket
* reconnect (frames missed while away are never replayed). Local mode has
* one user and reads nothing. Returns `{runId: [{id, name}]}`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { usePermissions } from "../components/AuthGate";
export type PanePeople = Record<string, { id: number; name: string }[]>;
export function usePanePresence(laneId: number | null | undefined): PanePeople {
const { local } = usePermissions();
const [panes, setPanes] = useState<PanePeople>({});
useEffect(() => {
setPanes({});
if (local || laneId == null) return;
let alive = true;
const load = () =>
Promise.resolve()
.then(() => api.lanes.presence(laneId))
.then((r) => alive && setPanes(r.panes))
.catch(() => {
/* presence is a nicety: no list, no chips */
});
void load();
const offMsg = eventBus.subscribe((msg) => {
if (msg.type !== "pane_presence") return;
const d = msg.data as { laneId?: number; runId?: string; users?: PanePeople[string] };
if (d.laneId !== laneId || !d.runId) return;
setPanes((prev) => {
const next = { ...prev };
if (d.users?.length) next[d.runId!] = d.users;
else delete next[d.runId!];
return next;
});
});
const offConn = eventBus.onConnection((up) => {
if (up) void load();
});
return () => {
alive = false;
offMsg();
offConn();
};
}, [laneId, local]);
return panes;
}
+5 -1
View File
@@ -1,4 +1,8 @@
{
"title": "Administration",
"description": "Accounts and the server AgentHub runs on."
"description": "Accounts and the server AgentHub runs on.",
"tabs": {
"users": "Users",
"server": "Server settings"
}
}
+7 -1
View File
@@ -206,7 +206,13 @@
"confirmPassword": "Confirm new password",
"mismatch": "The two new passwords differ",
"mustChangeSubmit": "Save and continue",
"saving": "Saving…"
"saving": "Saving…",
"tokenTitle": "Dashboard token",
"tokenSubtitle": "This dashboard requires its access token (DASHBOARD_TOKEN). It is saved in this browser only.",
"tokenWrong": "That token was not accepted — check it and try again.",
"tokenLabel": "Token",
"tokenHint": "On the dashboard's machine, `agenthub start` prints a link that carries it.",
"tokenSubmit": "Continue"
},
"insights": {
"help": {
+27
View File
@@ -386,6 +386,32 @@
"menu.copyPath": "Copy path",
"menu.reset": "Reset worktree…",
"menu.remove": "Delete workspace…",
"menu.share": "Share…",
"share.title": "Share workspace",
"share.what": "People you share it with see this workspace and its sessions, join its open terminals, type into them and commit — like you. Only you rename, reset, delete or share it. A session they start runs on their own account and is billed to them.",
"share.warning": "They work as YOUR account on this server: they can read everything in your home folder (your other workspaces, SSH keys and AI logins) and their commits carry your name. Share only with people you would hand your keyboard to.",
"share.loading": "Loading…",
"share.nobody": "There is no one else to share with.",
"share.search": "Search people",
"share.cancel": "Cancel",
"share.close": "Close",
"share.save": "Save",
"share.saving": "Saving…",
"share.confirmTitle": "Share \"{{name}}\"?",
"share.confirmBody": "{{people}} will work in it as your account and can read everything in your home folder.",
"share.confirm": "Share",
"share.saved_one": "Shared with {{count}} person",
"share.saved_other": "Shared with {{count}} people",
"share.disabled": "(disabled)",
"share.savedNone": "No longer shared with anyone",
"shared.ownAccount": "This workspace is shared with you: a session you start here runs on your own account — a pool account or an AI Proxy of yours — and is billed to you.",
"share.sessionTitle": "Share session",
"share.sessionWhat": "People you share it with see this session and the workspace's changes, join its open tab, type into it, send it messages (on their own account, billed to them) and commit — nothing else of the workspace. Only you share it. On this server they work as YOUR account inside its terminal, so they can still reach the rest of your home from there.",
"sessionMenu.share": "Share…",
"shared.sessionOnly": "Only a session of this workspace is shared with you: open it from the list to work in it.",
"tabs.billedTo": "Billed to {{name}}",
"tabs.here": "Also here: {{names}}",
"shared.badge": "Shared with you",
"panel.changes": "Changes",
"panel.analyst": "Analyst",
"panel.preview": "Preview",
@@ -709,6 +735,7 @@
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
"tmuxUpgrade": "Upgrade tmux: {{advice}}",
"tmuxUpgradeOptional": "tmux is older than recommended — terminals work as they are; this message shows once. {{advice}}",
"changes.rescanRepos": "Scan for repositories again",
"tree.newGroup": "New group",
"tree.groupDefaultName": "New group",
+31 -13
View File
@@ -1113,7 +1113,8 @@
}
},
"backPrevious": "Back",
"unknownSection": "There is no “{{section}}” settings page — showing Appearance instead."
"unknownSection": "There is no “{{section}}” settings page — showing Appearance instead.",
"movedToAdmin": "Settings that apply to the whole server (laptop connections, tests on this server, git logins, reports, model pricing, data…) are in Administration → Server settings."
},
"search": {
"placeholder": "Search settings",
@@ -1271,12 +1272,18 @@
"compactFailed": "Compact failed: {{error}}"
},
"backup": {
"title": "Daily backup",
"description": "A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.",
"title": "Database backup",
"description": "A full copy of the database on a schedule — every day, every week… — into a folder you choose; best on another disk, so a failed drive does not take the backups with it.",
"folder": "Backup folder",
"folderHint": "An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.",
"hour": "Time (server)",
"keep": "Backups kept",
"every": "How often",
"everyDay": "Every day",
"everyWeek": "Every week",
"everyTwoWeeks": "Every 2 weeks",
"everyDays_one": "Every {{count}} day",
"everyDays_other": "Every {{count}} days",
"runNow": "Back up now",
"running": "Backing up…",
"done": "Backup written: {{file}}",
@@ -1438,23 +1445,22 @@
}
},
"poolAccount": {
"title": "Pool account",
"description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.",
"automatic": "Automatic",
"select": "{{tool}} pool account",
"count_one": "{{count}} account you may use",
"count_other": "{{count}} accounts you may use",
"pinned": "An admin pinned you to {{account}}",
"expired": "expired",
"full": "quota full",
"automaticSaved": "Back to automatic — AgentHub picks the account",
"switchedNow": "Switched to {{account}} — running sessions follow it",
"nextSession": "{{account}} is used from your next session"
"nextSession": "{{account}} is used from your next session",
"inUse": "in use",
"badge": "Pool",
"automaticHint": "Your new sessions run on the least-used pool account",
"quota": "Quota {{pct}}%"
},
"companyHub": {
"title": "Company hub",
"description": "Use the company's shared Claude accounts from this machine. Only what costs the company is reported.",
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first.",
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first. Your folder paths, account name and machine name are replaced with ~ and device before sending.",
"ruleNotSent": "Sessions on your own login or your own AI Proxy send nothing — not even the folder name. The hub never reads your files and never controls this machine.",
"ruleKey": "The company account's credential stays on the hub; this machine keeps only a device token (90 days, revocable).",
"ruleOffice": "The hub is on the company network: away from it, Company-account sessions cannot start; your own login works as usual.",
@@ -1482,17 +1488,29 @@
"hubDescription": "Machines running AgentHub local that you enrolled with this hub, and their Company-account sessions.",
"noDevices": "No machine enrolled.",
"revokedAt": "revoked {{time}}",
"lastSeen": "seen {{time}} from {{ip}}",
"lastSeen": "seen {{time}}",
"revoke": "Revoke",
"revokeTitle": "Revoke {{name}}?",
"revokeHint": "Its device token stops working at once; the machine must enroll again.",
"revoked": "{{name}} revoked",
"yourPassword": "Your password",
"sessions": "Company-account sessions",
"unmonitored": "not started from AgentHub",
"sessionLine": "{{requests}} requests · {{input}} in · {{output}} out · {{account}}",
"purged": "conversation removed (retention)",
"whoRead": "Who opened your sessions",
"nobodyRead": "Nobody has opened your sessions."
"nobodyRead": "Nobody has opened your sessions.",
"policyTitle": "Allow laptop connections",
"policyOffHint": "Off: AgentHub is used on the server only. Laptops cannot enroll, and enrolled laptops can neither use company accounts nor send anything here.",
"policyOnHint": "On: members may run agents on their laptops with company accounts through this hub. That laptop's OS, the paths in its prompts and its device id still reach Anthropic.",
"policyOnTitle": "Turn laptop connections on?",
"policyOffTitle": "Turn laptop connections off?",
"policyOnConfirm": "Members will be able to use company accounts from their own laptops. Prompts from a laptop carry that laptop's details (OS, working folder).",
"policyOffConfirm": "Every enrolled laptop stops using company accounts at once; sessions running on a laptop will fail. Their own accounts keep working.",
"policyOnAction": "Turn on",
"policyOffAction": "Turn off",
"policyOnToast": "Laptop connections turned on",
"policyOffToast": "Laptop connections turned off",
"adminTitle": "Company hub",
"adminDescription": "Whether laptops running AgentHub local may connect to this server, and every machine people enrolled."
}
}
+12 -21
View File
@@ -42,8 +42,6 @@
"noOwner": "no owner",
"ownedBy": "owned by {{name}}",
"noWorkspaces": "There are no workspaces yet.",
"yourPassword": "Your password",
"yourPasswordHint": "Type your own password to confirm this change.",
"save": "Save",
"dialog": {
"add": "Add a user",
@@ -293,7 +291,7 @@
"noneShown": "No member matches."
},
"board": {
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Nothing is saved until Save.",
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Every change saves itself.",
"sharedHint": "Members and accounts in no pool",
"toShared": "Send {{name}} back to the shared pool",
"removeAccount": "Send {{name}} back to the shared pool",
@@ -303,33 +301,27 @@
"noSharedAccounts": "No account is left for the shared pool.",
"dropMembers": "Drop members here",
"untitled": "New pool",
"new": "not saved yet",
"new": "saving",
"pick": "Pick members of {{name}}",
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Applied on the board — Save sends it.",
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Saved when you Apply.",
"apply": "Apply",
"addAccount": "Move an account into {{name}}",
"addAccountOption": "+ Move an account or saved profile here…",
"profilesOf": "Saved {{tool}} profiles — moved into the pool on Save",
"profilePending": "A saved profile: it is moved into the pool when you save",
"pending_one": "{{count}} unsaved change",
"pending_other": "{{count}} unsaved changes",
"profilesOf": "Saved {{tool}} profiles — moved into the pool at once",
"profilePending": "A saved profile: being moved into the pool",
"needsName": "Every pool needs a name.",
"discard": "Discard",
"saveTitle": "Save the pools",
"saveHint": "Type your own password once to apply every change below.",
"summaryProfile": "Move the saved profile {{name}} into the pool",
"summaryNew": "Create {{name}}: {{count}} member(s), {{accounts}} account(s)",
"summaryGroup": "Set {{name}}: {{count}} member(s), {{accounts}} account(s)",
"saved": "Pools saved",
"dropAccounts": "Drag accounts here",
"noAccounts": "No accounts",
"accountsCount_one": "{{count}} account",
"accountsCount_other": "{{count}} accounts",
"mergeHint": "Drop to merge {{from}} into {{into}}",
"merged": "{{from}} merged into {{into}} — Save to apply",
"summaryMerge": "Merge {{from}} into {{into}}: pool {{from}} is deleted",
"merged": "{{from}} merged into {{into}}",
"newDrop": "Drop here to split it into a pool of its own",
"newHint": "Or drag an account or a member here to split it off"
"newHint": "Or drag an account or a member here to split it off",
"defaultName": "Pool {{n}}",
"saving": "Saving…",
"saveFailed": "Not saved: {{error}}",
"retry": "Retry"
},
"addAccount": "Add account",
"otherTools": "Other AI tools"
@@ -338,8 +330,7 @@
"button": "Delete user",
"title": "Delete {{name}}?",
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
"confirm": "Delete for good",
"needsPassword": "Enter your own password below first."
"confirm": "Delete for good"
},
"tmux": {
"title": "Members' tmux",
+5 -1
View File
@@ -1,4 +1,8 @@
{
"title": "Quản trị",
"description": "Tài khoản và máy chủ AgentHub đang chạy."
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
"tabs": {
"users": "Người dùng",
"server": "Cài đặt máy chủ"
}
}
+7 -1
View File
@@ -206,7 +206,13 @@
"confirmPassword": "Nhập lại mật khẩu mới",
"mismatch": "Hai mật khẩu mới không khớp",
"mustChangeSubmit": "Lưu và tiếp tục",
"saving": "Đang lưu…"
"saving": "Đang lưu…",
"tokenTitle": "Token của dashboard",
"tokenSubtitle": "Dashboard này yêu cầu token truy cập (DASHBOARD_TOKEN). Token chỉ được lưu trong trình duyệt này.",
"tokenWrong": "Token không đúng — kiểm tra lại rồi thử lần nữa.",
"tokenLabel": "Token",
"tokenHint": "Trên máy chạy dashboard, lệnh `agenthub start` in sẵn một đường link có kèm token.",
"tokenSubmit": "Tiếp tục"
},
"insights": {
"help": {
+27
View File
@@ -386,6 +386,32 @@
"menu.copyPath": "Sao chép đường dẫn",
"menu.reset": "Đặt lại worktree…",
"menu.remove": "Xóa workspace…",
"menu.share": "Chia sẻ…",
"share.title": "Chia sẻ workspace",
"share.what": "Người được chia sẻ thấy workspace này và các session của nó, vào được terminal đang mở, gõ và commit — như bạn. Chỉ bạn được đổi tên, đặt lại, xoá hay chia sẻ nó. Session họ mở chạy bằng tài khoản của chính họ và tính tiền cho họ.",
"share.warning": "Họ làm việc bằng TÀI KHOẢN CỦA BẠN trên server này: đọc được mọi thứ trong thư mục home của bạn (các workspace khác, SSH key, đăng nhập AI) và commit của họ mang tên bạn. Chỉ chia sẻ với người bạn tin như đưa bàn phím cho họ.",
"share.loading": "Đang tải…",
"share.nobody": "Không còn ai khác để chia sẻ.",
"share.search": "Tìm người",
"share.cancel": "Huỷ",
"share.close": "Đóng",
"share.save": "Lưu",
"share.saving": "Đang lưu…",
"share.confirmTitle": "Chia sẻ \"{{name}}\"?",
"share.confirmBody": "{{people}} sẽ làm việc trong đó bằng tài khoản của bạn và đọc được mọi thứ trong thư mục home của bạn.",
"share.confirm": "Chia sẻ",
"share.saved_one": "Đã chia sẻ với {{count}} người",
"share.saved_other": "Đã chia sẻ với {{count}} người",
"share.disabled": "(đã vô hiệu hoá)",
"share.savedNone": "Không còn chia sẻ với ai",
"shared.ownAccount": "Workspace này được chia sẻ với bạn: session bạn mở ở đây chạy bằng tài khoản của chính bạn — tài khoản pool hoặc AI Proxy của bạn — và tính tiền cho bạn.",
"share.sessionTitle": "Chia sẻ session",
"share.sessionWhat": "Người được chia sẻ thấy session này và các thay đổi của workspace, vào tab đang mở, gõ, gửi tin nhắn cho nó (bằng tài khoản của họ, tính tiền cho họ) và commit — không gì khác của workspace. Chỉ bạn chia sẻ được. Trên server này họ làm việc bằng TÀI KHOẢN CỦA BẠN trong terminal của nó, nên từ đó họ vẫn với tới được phần còn lại trong home của bạn.",
"sessionMenu.share": "Chia sẻ…",
"shared.sessionOnly": "Chỉ một session của workspace này được chia sẻ với bạn: mở nó từ danh sách để làm việc.",
"tabs.billedTo": "Tính tiền cho {{name}}",
"tabs.here": "Cũng đang mở: {{names}}",
"shared.badge": "Được chia sẻ với bạn",
"panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
"panel.preview": "Xem trang",
@@ -709,6 +735,7 @@
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}",
"tmuxUpgradeOptional": "tmux đã cũ — terminal vẫn chạy bình thường; thông báo này chỉ hiện một lần. {{advice}}",
"changes.rescanRepos": "Quét lại các repository",
"tree.newGroup": "Nhóm mới",
"tree.groupDefaultName": "Nhóm mới",
+31 -13
View File
@@ -1101,7 +1101,8 @@
}
},
"backPrevious": "Quay lại",
"unknownSection": "Không có trang cài đặt “{{section}}” — đang hiển thị trang Giao diện."
"unknownSection": "Không có trang cài đặt “{{section}}” — đang hiển thị trang Giao diện.",
"movedToAdmin": "Các cài đặt áp dụng cho cả máy chủ (kết nối laptop, test trên máy chủ, đăng nhập git, báo cáo, giá model, dữ liệu…) nằm ở Quản trị → Cài đặt máy chủ."
},
"search": {
"placeholder": "Tìm cài đặt",
@@ -1259,12 +1260,18 @@
"compactFailed": "Nén thất bại: {{error}}"
},
"backup": {
"title": "Backup hằng ngày",
"description": "Mỗi ngày một bản sao đầy đủ của cơ sở dữ liệu vào thư mục bạn chọn — tốt nhất là trên một ổ đĩa khác, để hỏng một ổ không mất luôn bản backup.",
"title": "Sao lưu database",
"description": "Bản sao đầy đủ của database theo lịch — mỗi ngày, mỗi tuần… — vào thư mục bạn chọn; nên đặt ở ổ khác để ổ hỏng không kéo theo bản sao lưu.",
"folder": "Thư mục backup",
"folderHint": "Đường dẫn tuyệt đối tới một thư mục có sẵn mà server ghi được. Để trống để tắt backup. AgentHub chỉ xóa đúng các tệp agenthub-YYYYMMDD-HHMMSS.db của chính nó ở đây, bản cũ nhất trước khi vượt số bản giữ lại.",
"hour": "Giờ chạy (giờ server)",
"keep": "Số bản giữ lại",
"every": "Bao lâu một lần",
"everyDay": "Mỗi ngày",
"everyWeek": "Mỗi tuần",
"everyTwoWeeks": "2 tuần một lần",
"everyDays_one": "{{count}} ngày một lần",
"everyDays_other": "{{count}} ngày một lần",
"runNow": "Backup ngay",
"running": "Đang backup…",
"done": "Đã ghi bản backup: {{file}}",
@@ -1426,23 +1433,22 @@
}
},
"poolAccount": {
"title": "Tài khoản pool",
"description": "Tài khoản nào trong pool tài khoản dùng chung chạy các phiên mới của bạn. Tự động để AgentHub chọn tài khoản ít dùng nhất.",
"automatic": "Tự động",
"select": "Tài khoản pool của {{tool}}",
"count_one": "{{count}} tài khoản bạn được dùng",
"count_other": "{{count}} tài khoản bạn được dùng",
"pinned": "Admin đã ghim bạn vào {{account}}",
"expired": "hết hạn",
"full": "hết quota",
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
"nextSession": "{{account}} được dùng từ phiên tiếp theo",
"inUse": "đang dùng",
"badge": "Pool",
"automaticHint": "Phiên mới của bạn chạy trên tài khoản pool ít dùng nhất",
"quota": "Quota {{pct}}%"
},
"companyHub": {
"title": "Hub công ty",
"description": "Dùng tài khoản Claude chung của công ty từ máy này. Chỉ phần tốn tiền công ty mới được ghi nhận.",
"ruleSent": "Session bạn mở bằng \"Tài khoản công ty\" sẽ gửi hội thoại và token đã dùng lên hub — mỗi giờ và khi session kết thúc. Các chuỗi giống bí mật (key, token, mật khẩu) được xoá trước khi gửi.",
"ruleSent": "Session bạn mở bằng \"Tài khoản công ty\" sẽ gửi hội thoại và token đã dùng lên hub — mỗi giờ và khi session kết thúc. Các chuỗi giống bí mật (key, token, mật khẩu) được xoá trước khi gửi. Đường dẫn thư mục, tên tài khoản và tên máy của bạn được thay bằng ~ và device trước khi gửi.",
"ruleNotSent": "Session dùng tài khoản riêng hoặc AI Proxy riêng của bạn không gửi gì cả — kể cả tên thư mục. Hub không đọc file của bạn và không điều khiển máy này.",
"ruleKey": "Key của tài khoản công ty nằm ở hub; máy này chỉ giữ một device token (90 ngày, thu hồi được).",
"ruleOffice": "Hub nằm trong mạng công ty: ở ngoài, session tài khoản công ty không mở được; tài khoản riêng vẫn dùng bình thường.",
@@ -1470,17 +1476,29 @@
"hubDescription": "Các máy chạy AgentHub local bạn đã đăng ký với hub này, và các session tài khoản công ty của chúng.",
"noDevices": "Chưa có máy nào đăng ký.",
"revokedAt": "đã thu hồi {{time}}",
"lastSeen": "thấy lần cuối {{time}} từ {{ip}}",
"lastSeen": "thấy lần cuối {{time}}",
"revoke": "Thu hồi",
"revokeTitle": "Thu hồi {{name}}?",
"revokeHint": "Device token ngừng hoạt động ngay; máy đó phải đăng ký lại.",
"revoked": "Đã thu hồi {{name}}",
"yourPassword": "Mật khẩu của bạn",
"sessions": "Session tài khoản công ty",
"unmonitored": "không mở từ AgentHub",
"sessionLine": "{{requests}} request · {{input}} vào · {{output}} ra · {{account}}",
"purged": "đã xoá hội thoại (quá hạn lưu)",
"whoRead": "Ai đã xem session của bạn",
"nobodyRead": "Chưa ai xem session của bạn."
"nobodyRead": "Chưa ai xem session của bạn.",
"policyTitle": "Cho phép kết nối laptop",
"policyOffHint": "Đang tắt: chỉ dùng AgentHub trên máy chủ. Laptop không đăng ký được, các laptop đã đăng ký không dùng được tài khoản công ty và không gửi gì lên đây.",
"policyOnHint": "Đang bật: member có thể chạy agent trên laptop bằng tài khoản công ty qua hub này. Hệ điều hành, đường dẫn trong prompt và mã thiết bị của laptop đó vẫn đi tới Anthropic.",
"policyOnTitle": "Bật kết nối laptop?",
"policyOffTitle": "Tắt kết nối laptop?",
"policyOnConfirm": "Member sẽ dùng được tài khoản công ty từ laptop của họ. Prompt từ laptop mang thông tin của chính laptop đó (hệ điều hành, thư mục làm việc).",
"policyOffConfirm": "Mọi laptop đã đăng ký ngừng dùng được tài khoản công ty ngay; session đang chạy trên laptop sẽ báo lỗi. Tài khoản riêng của họ vẫn chạy bình thường.",
"policyOnAction": "Bật",
"policyOffAction": "Tắt",
"policyOnToast": "Đã bật kết nối laptop",
"policyOffToast": "Đã tắt kết nối laptop",
"adminTitle": "Hub công ty",
"adminDescription": "Cho phép hay không cho laptop chạy AgentHub local kết nối vào máy chủ này, và các máy mọi người đã đăng ký."
}
}
+12 -21
View File
@@ -42,8 +42,6 @@
"noOwner": "chưa có chủ",
"ownedBy": "của {{name}}",
"noWorkspaces": "Chưa có workspace nào.",
"yourPassword": "Mật khẩu của bạn",
"yourPasswordHint": "Nhập mật khẩu của chính bạn để xác nhận thay đổi này.",
"save": "Lưu",
"dialog": {
"add": "Thêm người dùng",
@@ -293,7 +291,7 @@
"noneShown": "Không có thành viên nào khớp."
},
"board": {
"howTo": "Kéo tài khoản hoặc member giữa các pool; thả vào ô Tạo pool để tách ra, hoặc kéo tài khoản duy nhất của một pool sang pool khác để gộp. Chỉ lưu khi bấm Lưu.",
"howTo": "Kéo tài khoản hoặc member giữa các pool; thả vào ô Tạo pool để tách ra, hoặc kéo tài khoản duy nhất của một pool sang pool khác để gộp. Mọi thay đổi tự lưu ngay.",
"sharedHint": "Member và tài khoản chưa thuộc pool nào",
"toShared": "Đưa {{name}} về pool chung",
"removeAccount": "Đưa {{name}} về pool chung",
@@ -303,33 +301,27 @@
"noSharedAccounts": "Không còn tài khoản nào cho pool chung.",
"dropMembers": "Thả thành viên vào đây",
"untitled": "Pool mới",
"new": "chưa lưu",
"new": "đang lưu",
"pick": "Chọn thành viên cho {{name}}",
"pickHint": "Lọc theo role hoặc \"Chưa có pool\", tick người thuộc pool này. Mỗi người chỉ ở một pool, tick sẽ chuyển họ vào đây. Áp dụng lên bảng — bấm Lưu mới gửi.",
"pickHint": "Lọc theo role hoặc \"Chưa có pool\", tick người thuộc pool này. Mỗi người chỉ ở một pool, tick sẽ chuyển họ vào đây. Tự lưu khi bấm Áp dụng.",
"apply": "Áp dụng",
"addAccount": "Chuyển tài khoản vào {{name}}",
"addAccountOption": "+ Chuyển tài khoản hoặc profile đã lưu vào đây…",
"profilesOf": "Profile {{tool}} đã lưu — chuyển vào pool khi Lưu",
"profilePending": "Profile đã lưu: sẽ được chuyển vào pool khi bạn lưu",
"pending_one": "{{count}} thay đổi chưa lưu",
"pending_other": "{{count}} thay đổi chưa lưu",
"profilesOf": "Profile {{tool}} đã lưu — chuyển vào pool ngay",
"profilePending": "Profile đã lưu: đang được chuyển vào pool",
"needsName": "Pool nào cũng cần có tên.",
"discard": "Bỏ thay đổi",
"saveTitle": "Lưu các pool",
"saveHint": "Nhập mật khẩu của bạn một lần để áp dụng mọi thay đổi bên dưới.",
"summaryProfile": "Chuyển profile {{name}} vào pool",
"summaryNew": "Tạo {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
"summaryGroup": "Cập nhật {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
"saved": "Đã lưu các pool",
"dropAccounts": "Kéo tài khoản vào đây",
"noAccounts": "Chưa có tài khoản",
"accountsCount_one": "{{count}} tài khoản",
"accountsCount_other": "{{count}} tài khoản",
"mergeHint": "Thả để gộp {{from}} vào {{into}}",
"merged": "Đã gộp {{from}} vào {{into}} — bấm Lưu để áp dụng",
"summaryMerge": "Gộp {{from}} vào {{into}}: xoá pool {{from}}",
"merged": "Đã gộp {{from}} vào {{into}}",
"newDrop": "Thả vào đây để tách thành pool riêng",
"newHint": "Hoặc kéo tài khoản / member vào đây để tách ra"
"newHint": "Hoặc kéo tài khoản / member vào đây để tách ra",
"defaultName": "Pool {{n}}",
"saving": "Đang lưu…",
"saveFailed": "Chưa lưu được: {{error}}",
"retry": "Thử lại"
},
"addAccount": "Thêm tài khoản",
"otherTools": "AI tool khác"
@@ -338,8 +330,7 @@
"button": "Xoá user",
"title": "Xoá {{name}}?",
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
"confirm": "Xoá vĩnh viễn",
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
"confirm": "Xoá vĩnh viễn"
},
"tmux": {
"title": "Tmux của member",
@@ -0,0 +1,51 @@
/**
* @file apiDashboardToken.test.ts
* @description DASHBOARD_TOKEN in the browser: `withToken` adds `?token=` (or
* `&token=`) to what cannot send the header — WebSockets, images, downloads —
* and leaves the URL alone with no token; `adoptTokenFromUrl` keeps the token
* of the link `agenthub start` prints and takes it out of the address bar;
* a 401 EUNAUTHORIZED from any request fires TOKEN_REQUIRED_EVENT.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { adoptTokenFromUrl, api, TOKEN_REQUIRED_EVENT, withToken } from "../api";
beforeEach(() => localStorage.clear());
afterEach(() => vi.unstubAllGlobals());
describe("the dashboard token in the browser", () => {
it("rides the query only when there is one", () => {
expect(withToken("/api/x")).toBe("/api/x");
localStorage.setItem("dashboard_token", "a b");
expect(withToken("/api/x")).toBe("/api/x?token=a%20b");
expect(withToken("/api/x?path=1")).toBe("/api/x?path=1&token=a%20b");
expect(api.artifacts.rawUrl(3, "f.png")).toBe("/api/artifacts/3/raw?path=f.png&token=a%20b");
});
it("is taken from the page's ?token= and dropped from the address bar", () => {
window.history.replaceState(null, "", "/workspace?lane=2&token=t0k#x");
adoptTokenFromUrl();
expect(localStorage.getItem("dashboard_token")).toBe("t0k");
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
"/workspace?lane=2#x"
);
});
it("is asked for when a request answers 401 EUNAUTHORIZED", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: { code: "EUNAUTHORIZED", message: "no" } }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
)
);
const seen = vi.fn();
window.addEventListener(TOKEN_REQUIRED_EVENT, seen);
await expect(api.auth.me()).rejects.toMatchObject({ status: 401 });
window.removeEventListener(TOKEN_REQUIRED_EVENT, seen);
expect(seen).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,202 @@
/**
* @file imageMetadata.test.ts
* @description Tests for the photo-metadata scrubber: JPEG keeps only its
* orientation and loses EXIF/XMP/comments and trailing data, PNG loses its
* text/EXIF chunks, WebP its EXIF/XMP chunks and flags; anything else, or a
* clean image, comes back as the very same object.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { scrubImage, scrubImageBytes, scrubImages } from "../imageMetadata";
const bytes = (...parts: (number[] | string | Uint8Array)[]) => {
const flat: number[] = [];
for (const p of parts) {
if (typeof p === "string") for (const c of p) flat.push(c.charCodeAt(0));
else flat.push(...p);
}
return new Uint8Array(flat);
};
const read = (b: Blob) =>
new Promise<Uint8Array>((ok, no) => {
const r = new FileReader();
r.onload = () => ok(new Uint8Array(r.result as ArrayBuffer));
r.onerror = () => no(r.error);
r.readAsArrayBuffer(b);
});
const indexOf = (hay: Uint8Array, needle: string) => {
const n = [...needle].map((c) => c.charCodeAt(0));
outer: for (let i = 0; i + n.length <= hay.length; i++) {
for (let j = 0; j < n.length; j++) if (hay[i + j] !== n[j]) continue outer;
return i;
}
return -1;
};
const has = (hay: Uint8Array, needle: string) => indexOf(hay, needle) >= 0;
const seg = (marker: number, body: Uint8Array) =>
bytes([0xff, marker, (body.length + 2) >> 8, (body.length + 2) & 0xff], body);
/** EXIF payload: orientation 6 plus a GPS-looking string. */
function exif(orientation: number) {
const tiff = new Uint8Array(26);
const dv = new DataView(tiff.buffer);
tiff.set([0x4d, 0x4d, 0, 0x2a]); // "MM" big-endian
dv.setUint32(4, 8);
dv.setUint16(8, 1);
dv.setUint16(10, 0x0112);
dv.setUint16(12, 3);
dv.setUint32(14, 1);
dv.setUint16(18, orientation);
dv.setUint32(22, 0);
return bytes("Exif\0\0", tiff, "GPS 10.7769N 106.7009E");
}
function jpeg(opts: { orientation?: number; trailer?: boolean } = {}) {
return bytes(
[0xff, 0xd8],
seg(0xe0, bytes("JFIF\0", [1, 1, 0, 0, 1, 0, 1, 0, 0])),
seg(0xe1, exif(opts.orientation ?? 6)),
seg(0xe1, bytes("http://ns.adobe.com/xap/1.0/\0<x:xmpmeta>Owner Name</x:xmpmeta>")),
seg(0xe2, bytes("ICC_PROFILE\0", [1, 1, 9, 9])),
seg(0xfe, bytes("comment: secret")),
seg(0xdb, bytes([0, 1, 2, 3])),
seg(0xda, bytes([0, 1, 2])),
[0x12, 0x34, 0xff, 0x00, 0x56, 0xff, 0xd0, 0x78],
[0xff, 0xd9],
opts.trailer ? bytes([0xff, 0xd8], seg(0xe1, exif(1)), "MOTION VIDEO") : []
);
}
describe("scrubImageBytes — JPEG", () => {
it("drops EXIF, XMP and comments but keeps JFIF, ICC, tables and the scan", () => {
const out = scrubImageBytes(jpeg({ orientation: 1 }))!;
expect(out).not.toBeNull();
expect(has(out, "GPS")).toBe(false);
expect(has(out, "Owner Name")).toBe(false);
expect(has(out, "secret")).toBe(false);
expect(has(out, "Exif")).toBe(false);
expect(has(out, "JFIF")).toBe(true);
expect(has(out, "ICC_PROFILE")).toBe(true);
// The scan (header + entropy data with a stuffed byte and a restart marker) is intact.
expect([...out.slice(-15)]).toEqual(
[0xff, 0xda, 0, 5, 0, 1, 2, 0x12, 0x34, 0xff, 0x00, 0x56, 0xff, 0xd0, 0x78]
.concat([0xff, 0xd9])
.slice(-15)
);
});
it("keeps the orientation in a minimal EXIF so the photo is not shown sideways", () => {
const out = scrubImageBytes(jpeg({ orientation: 6 }))!;
expect(has(out, "GPS")).toBe(false);
const at = indexOf(out, "Exif\0\0");
expect(at).toBeGreaterThan(0);
const dv = new DataView(out.buffer, out.byteOffset + at + 6);
expect(dv.getUint16(2, true)).toBe(42); // "II*\0"
expect(dv.getUint16(8, true)).toBe(1); // one entry …
expect(dv.getUint16(10, true)).toBe(0x0112); // … the orientation …
expect(dv.getUint16(18, true)).toBe(6); // … with its value
});
it("cuts what a phone appends after the image (extra pictures, motion video)", () => {
const out = scrubImageBytes(jpeg({ trailer: true }))!;
expect(has(out, "MOTION VIDEO")).toBe(false);
expect([...out.slice(-2)]).toEqual([0xff, 0xd9]);
});
it("returns null for a JPEG with nothing to remove", () => {
const clean = bytes(
[0xff, 0xd8],
seg(0xdb, bytes([0, 1])),
seg(0xda, bytes([0])),
[1, 2],
[0xff, 0xd9]
);
expect(scrubImageBytes(clean)).toBeNull();
});
it("returns null for a truncated JPEG instead of throwing", () => {
expect(scrubImageBytes(jpeg().slice(0, 30))).toBeNull();
});
});
function pngChunk(type: string, data: Uint8Array) {
const len = new Uint8Array(4);
new DataView(len.buffer).setUint32(0, data.length);
return bytes(len, type, data, [0, 0, 0, 0]);
}
describe("scrubImageBytes — PNG", () => {
it("drops tEXt/iTXt/zTXt/eXIf/tIME and anything after IEND", () => {
const png = bytes(
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
pngChunk("IHDR", new Uint8Array(13)),
pngChunk("tEXt", bytes("Author\0Owner Name")),
pngChunk("eXIf", bytes("MM GPS")),
pngChunk("IDAT", bytes([1, 2, 3])),
pngChunk("iTXt", bytes("Comment\0secret")),
pngChunk("IEND", new Uint8Array(0)),
"TRAILING"
);
const out = scrubImageBytes(png)!;
expect(has(out, "Owner Name")).toBe(false);
expect(has(out, "GPS")).toBe(false);
expect(has(out, "secret")).toBe(false);
expect(has(out, "TRAILING")).toBe(false);
expect(has(out, "IHDR")).toBe(true);
expect(has(out, "IDAT")).toBe(true);
expect(has(out, "IEND")).toBe(true);
});
});
describe("scrubImageBytes — WebP", () => {
it("drops EXIF/XMP chunks, clears their VP8X flags and fixes the RIFF size", () => {
const chunk = (tag: string, data: Uint8Array) => {
const size = new Uint8Array(4);
new DataView(size.buffer).setUint32(0, data.length, true);
return bytes(tag, size, data, data.length & 1 ? [0] : []);
};
const body = bytes(
"WEBP",
chunk("VP8X", bytes([0x0c, 0, 0, 0, 0, 0, 0, 0, 0, 0])),
chunk("VP8 ", bytes([9, 9, 9, 9])),
chunk("EXIF", bytes("GPS 10N")),
chunk("XMP ", bytes("Owner Name"))
);
const size = new Uint8Array(4);
new DataView(size.buffer).setUint32(0, body.length, true);
const out = scrubImageBytes(bytes("RIFF", size, body))!;
expect(has(out, "GPS")).toBe(false);
expect(has(out, "Owner Name")).toBe(false);
const dv = new DataView(out.buffer);
expect(dv.getUint32(4, true)).toBe(out.length - 8);
expect(out[20]! & 0x0c).toBe(0);
});
});
describe("scrubImage", () => {
it("returns a File with the same name, type and date, minus the metadata", async () => {
const f = new File([jpeg()], "IMG_0001.JPG", { type: "image/jpeg", lastModified: 1234 });
const out = await scrubImage(f);
expect(out).not.toBe(f);
expect(out).toBeInstanceOf(File);
expect(out.name).toBe("IMG_0001.JPG");
expect(out.type).toBe("image/jpeg");
expect(out.lastModified).toBe(1234);
expect(has(await read(out), "GPS")).toBe(false);
});
it("leaves non-images and clean images untouched (same object)", async () => {
const txt = new File(["Exif GPS"], "notes.txt", { type: "text/plain" });
expect(await scrubImage(txt)).toBe(txt);
const fake = new File(["not a jpeg"], "x.jpg", { type: "image/jpeg" });
expect(await scrubImage(fake)).toBe(fake);
});
it("scrubs each file of a batch and keeps the other fields", async () => {
const f = new File([jpeg()], "a.jpg", { type: "image/jpeg" });
const [one] = await scrubImages([{ file: f, rel: "pics/a.jpg" }]);
expect(one!.rel).toBe("pics/a.jpg");
expect(has(await read(one!.file), "GPS")).toBe(false);
});
});
+213 -97
View File
@@ -452,6 +452,7 @@ import { activeSourcesParam } from "./dataScope";
import i18n from "i18next";
import { applyRange, type DateRange } from "./dateRange";
import { emitGitChanged } from "./gitChanged";
import { scrubImage, scrubImages } from "./imageMetadata";
// Root path all endpoint paths are appended to. Kept relative (no host) so the
// same client bundle works behind the Vite dev proxy and in same-origin prod.
@@ -505,6 +506,24 @@ export function dashboardToken(): string | null {
}
}
/**
* `url` with the dashboard token (if one is configured) in its query string —
* for what cannot send the `x-dashboard-token` header: an `<img>`, an
* `<a download>`, an iframe, a WebSocket, a bare `fetch`. Unchanged when no
* token is set (the loopback default).
*/
export function withToken(url: string): string {
const token = dashboardToken();
if (!token) return url;
return `${url}${url.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
}
/** The token header for a bare `fetch` that bypasses {@link request}; empty with no token. */
export function tokenHeader(): Record<string, string> {
const token = dashboardToken();
return token ? { "x-dashboard-token": token } : {};
}
/** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */
function ccQuery(scope?: CcScope, cwd?: string): string {
const qs = new URLSearchParams();
@@ -546,6 +565,36 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
*/
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** Fired on a 401 EUNAUTHORIZED: DASHBOARD_TOKEN is set and this browser's is missing or wrong. */
export const TOKEN_REQUIRED_EVENT = "agenthub:token-required";
/** Keep `token` as this browser's dashboard token (an empty one forgets it). */
export function saveDashboardToken(token: string): void {
try {
if (token) localStorage.setItem("dashboard_token", token);
else localStorage.removeItem("dashboard_token");
} catch {
/* storage blocked: the prompt will ask again */
}
}
/**
* A `?token=` on the page's own URL (the link `agenthub start` prints): keep
* it in this browser and take it out of the address bar and history, so it is
* not left in a screenshot or a bookmark. Call once, before the first request.
*/
export function adoptTokenFromUrl(): void {
try {
const url = new URL(window.location.href);
const token = url.searchParams.get("token");
if (!token) return;
saveDashboardToken(token);
url.searchParams.delete("token");
window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
} catch {
/* no URL API / history: nothing to adopt */
}
}
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
@@ -940,6 +989,15 @@ export type PoolProfile = {
subscriptionType: string | null;
active: boolean;
};
/** A user a workspace is shared with (`/api/lanes/:id/shares`) — names only. */
export type LaneShare = {
id: number;
username: string;
name: string;
sharedAt: string;
/** Disabled since it was shared: kept on the list, granting nothing. */
disabled?: boolean;
};
/** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */
export type ToolProfileIdentity = {
/** codex: `chatgpt` | `apikey`; omp: `credentials`. */
@@ -1057,6 +1115,10 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
if (res.status === 401 && body?.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
// DASHBOARD_TOKEN: this browser has none or a wrong one — AuthGate asks.
if (res.status === 401 && body?.error?.code === "EUNAUTHORIZED") {
window.dispatchEvent(new Event(TOKEN_REQUIRED_EVENT));
}
// Server mode: a role refusal reads the same in every toast, translated.
const message =
body?.error?.code === "EADMIN"
@@ -1203,6 +1265,9 @@ const sendRaw = <T>(
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
if (xhr.status === 401 && body.error?.code === "EUNAUTHORIZED") {
window.dispatchEvent(new Event(TOKEN_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
@@ -1537,12 +1602,12 @@ export const api = {
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
/** URL of this account's avatar, versioned by `avatarAt` (null = none). */
avatarUrl: (avatarAt?: string | null) =>
avatarAt ? `${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}` : null,
avatarAt ? withToken(`${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}`) : null,
/** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */
setAvatar: (file: Blob) =>
setAvatar: async (file: Blob) =>
request<MyAccount>("/me/avatar", {
method: "PUT",
body: file,
body: await scrubImage(file),
headers: { "Content-Type": "application/octet-stream" },
}),
/** POST /api/me/avatar/url — the server downloads the picture from an https link. */
@@ -1570,17 +1635,17 @@ export const api = {
users: {
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
avatarAt ? withToken(`${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}`) : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () =>
request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>(
`/users?tz_offset=${new Date().getTimezoneOffset()}`
),
/** PUT /api/users/quota-default — the default spend quota (null = none). */
setQuotaDefault: (quota: UserQuota | null, currentPassword: string) =>
setQuotaDefault: (quota: UserQuota | null) =>
request<{ user: AuthUser; quotaDefault: UserQuota | null }>("/users/quota-default", {
method: "PUT",
body: JSON.stringify({ quota, currentPassword }),
body: JSON.stringify({ quota }),
}),
/** GET /api/users/:id/details — one account in full (admin/analyst). */
details: (id: number) =>
@@ -1594,65 +1659,61 @@ export const api = {
request<{ storage: Record<string, UserStorage>; measuring: boolean }>(
`/users/storage${refresh ? "?refresh=1" : ""}`
),
// Account changes (admin only). Each carries the admin's OWN password
// again (`currentPassword`): the server refuses without it (EREAUTH).
// Account changes (admin only — the server checks the role).
/** POST /api/users — a new account. */
create: (
body: { username: string; password: string; role: AuthUser["role"] },
currentPassword: string
) =>
create: (body: { username: string; password: string; role: AuthUser["role"] }) =>
request<{ user: AuthUser & { disabled: boolean } }>("/users", {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
body: JSON.stringify({ ...body }),
}),
/** GET /api/users/tmux — each member's own tmux server beside the installed version (S3). */
tmux: () => request<MemberTmux>("/users/tmux"),
/** POST /api/users/:id/tmux-restart — end that member's tmux server (every pane of theirs). */
tmuxRestart: (id: number, currentPassword: string) =>
tmuxRestart: (id: number) =>
request<{ user: AuthUser }>(`/users/${id}/tmux-restart`, {
method: "POST",
body: JSON.stringify({ currentPassword }),
body: JSON.stringify({}),
}),
/** POST /api/users/tmux-restart-idle — restart every outdated member server with no agent working. */
tmuxRestartIdle: (currentPassword: string) =>
tmuxRestartIdle: () =>
request<{
restarted: string[];
skipped: string[];
failed: { username: string; message: string }[];
}>("/users/tmux-restart-idle", {
method: "POST",
body: JSON.stringify({ currentPassword }),
body: JSON.stringify({}),
}),
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
get: (id: number) => request<UserDetail>(`/users/${id}`),
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
* sign-out in ONE change (never your own role or disabling yourself). */
update: (id: number, body: UserPatch, currentPassword: string) =>
update: (id: number, body: UserPatch) =>
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify({ ...body, currentPassword }),
body: JSON.stringify({ ...body }),
}),
/**
* DELETE /api/users/:id — delete the user for good: logins, their
* workspaces' records and (one Linux user per account) their Linux
* account and whole home. Never yourself or the last admin.
*/
remove: (id: number, currentPassword: string) =>
remove: (id: number) =>
request<{ user: AuthUser }>(`/users/${id}`, {
method: "DELETE",
body: JSON.stringify({ currentPassword }),
body: JSON.stringify({}),
}),
/** POST /api/users/:id/password — a new password; that user's logins end. */
setPassword: (id: number, password: string, currentPassword: string) =>
setPassword: (id: number, password: string) =>
request<{ user: AuthUser }>(`/users/${id}/password`, {
method: "POST",
body: JSON.stringify({ password, currentPassword }),
body: JSON.stringify({ password }),
}),
/** POST /api/users/:id/workspaces — give that user a workspace. */
assignWorkspace: (id: number, laneId: number, currentPassword: string) =>
assignWorkspace: (id: number, laneId: number) =>
request<{ user: AuthUser }>(`/users/${id}/workspaces`, {
method: "POST",
body: JSON.stringify({ laneId, currentPassword }),
body: JSON.stringify({ laneId }),
}),
},
/** Login profiles of Codex, Oh My Pi… (Claude's: `ccConfig.profiles`). */
@@ -1687,19 +1748,19 @@ export const api = {
accountPool: {
/** GET /api/accounts — admin/analyst; `force` re-reads every quota. */
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
/** POST /api/accounts — admin, with their OWN password again. */
/** POST /api/accounts — admin. */
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
profiles: (tool: PoolTool = "claude") =>
request<{ profiles: PoolProfile[] }>(
tool === "codex" ? "/accounts/profiles?tool=codex" : "/accounts/profiles"
),
add: (body: PoolAccountInput, currentPassword: string) =>
add: (body: PoolAccountInput) =>
request<{ account: { name: string } }>("/accounts", {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
body: JSON.stringify({ ...body }),
}),
/** PUT /api/accounts/:name/users — admin: exactly these users run on this account. */
setUsers: (tool: PoolTool, name: string, userIds: number[], currentPassword: string) =>
setUsers: (tool: PoolTool, name: string, userIds: number[]) =>
request<{
/** Users moved onto it; `switched`: what happened now (a Claude login goes into their home). */
added: {
@@ -1710,31 +1771,31 @@ export const api = {
removed: { userId: number; username: string }[];
}>(`/accounts/${encodeURIComponent(name)}/users`, {
method: "PUT",
body: JSON.stringify({ tool, userIds, currentPassword }),
body: JSON.stringify({ tool, userIds }),
}),
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
remove: (tool: PoolTool, name: string, currentPassword: string) =>
/** DELETE /api/accounts/:name — admin. */
remove: (tool: PoolTool, name: string) =>
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
method: "DELETE",
body: JSON.stringify({ tool, currentPassword }),
body: JSON.stringify({ tool }),
}),
/** POST /api/accounts/groups — admin: a new, empty pool group. */
createGroup: (name: string, currentPassword: string) =>
createGroup: (name: string) =>
request<{ group: { id: number; name: string } }>("/accounts/groups", {
method: "POST",
body: JSON.stringify({ name, currentPassword }),
body: JSON.stringify({ name }),
}),
/** PATCH /api/accounts/groups/:id — admin: each part given is set exactly. */
updateGroup: (id: number, body: PoolGroupPatch, currentPassword: string) =>
updateGroup: (id: number, body: PoolGroupPatch) =>
request<{ group: PoolGroup }>(`/accounts/groups/${id}`, {
method: "PATCH",
body: JSON.stringify({ ...body, currentPassword }),
body: JSON.stringify({ ...body }),
}),
/** DELETE /api/accounts/groups/:id — its members go back to the shared pool. */
deleteGroup: (id: number, currentPassword: string) =>
deleteGroup: (id: number) =>
request<{ ok: true }>(`/accounts/groups/${id}`, {
method: "DELETE",
body: JSON.stringify({ currentPassword }),
body: JSON.stringify({}),
}),
},
// ───────────────────────── Updates / self-update API ─────────────────────────
@@ -1932,7 +1993,9 @@ export const api = {
/** GET /api/sessions/:id/image — an image the session's Read tool read,
* wherever it lies (local mode only), for an `<img>`. */
readImageUrl: (id: string, path: string) =>
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
withToken(
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`
),
/** GET /api/sessions/:id/file — a text file the session itself read or
* wrote, wherever it lies (an output outside the workspace). */
readFile: (id: string, path: string) =>
@@ -2317,12 +2380,12 @@ export const api = {
body: JSON.stringify(data),
}),
},
/** The daily database backup (`server/lib/db-backup.js`). */
/** The scheduled database backup (`server/lib/db-backup.js`). */
backup: {
/** GET /api/settings/backup */
get: () => request<BackupState>("/settings/backup"),
/** PUT /api/settings/backup — any subset; an empty `dir` turns it off */
save: (data: { dir?: string; hour?: number; keep?: number }) =>
save: (data: { dir?: string; hour?: number; keep?: number; every?: number }) =>
request<BackupState & { ok: boolean }>("/settings/backup", {
method: "PUT",
body: JSON.stringify(data),
@@ -2442,7 +2505,7 @@ export const api = {
*
* @returns The absolute-on-origin URL (`/api/settings/export`) to link to.
*/
exportData: () => `${BASE}/settings/export`,
exportData: () => withToken(`${BASE}/settings/export`),
/**
* POST /api/settings/import (multipart) - restore a bundle previously
* produced by {@link exportData}. Idempotent and non-destructive: sessions
@@ -2451,7 +2514,7 @@ export const api = {
*
* Like {@link api.import.upload}, this bypasses {@link request} to send a
* `multipart/form-data` body (field name "file") and let the browser set the
* boundary. No auth token is attached (local/zero-config flow).
* boundary. The dashboard token, when one is set, rides the header.
*
* @param file The `.json` export file the user selected.
* @returns {@link ImportBackupResult} — per-table restore counts.
@@ -2460,7 +2523,11 @@ export const api = {
importData: async (file: File): Promise<ImportBackupResult> => {
const form = new FormData();
form.append("file", file, file.name);
const res = await fetch(`${BASE}/settings/import`, { method: "POST", body: form });
const res = await fetch(`${BASE}/settings/import`, {
method: "POST",
body: form,
headers: tokenHeader(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `HTTP ${res.status}`);
@@ -2769,7 +2836,11 @@ export const api = {
// Append each file under the repeated "files" field, keeping its filename.
for (const f of files) form.append("files", f, f.name);
// Do NOT set Content-Type manually: the browser adds the multipart boundary.
const res = await fetch(`${BASE}/import/upload`, { method: "POST", body: form });
const res = await fetch(`${BASE}/import/upload`, {
method: "POST",
body: form,
headers: tokenHeader(),
});
if (!res.ok) {
// Same error-shaping contract as request(), duplicated because this call
// intentionally does not route through the JSON wrapper.
@@ -3358,8 +3429,8 @@ export const api = {
/** One file's download URL, on a workspace or a home. */
downloadUrl: (t: FsPlace, path: string) =>
isHomePlace(t)
? `${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`
: `${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`,
? withToken(`${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`)
: withToken(`${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`),
list: (t: FsPlace, path = "") =>
request<FsListing>(fsUrl(t, "/list", new URLSearchParams({ path }))),
mkdir: (t: FsPlace, path: string) =>
@@ -3421,7 +3492,7 @@ export const api = {
zipUrl: (t: FsPlace, paths: string[]) => {
const q = new URLSearchParams();
for (const p of paths) q.append("p", p);
return `${BASE}${fsUrl(t, "/zip", q)}`;
return withToken(`${BASE}${fsUrl(t, "/zip", q)}`);
},
/**
* Stream `file` to `path` (sub-folders made as needed), reporting upload
@@ -3435,11 +3506,13 @@ export const api = {
onProgress?: (sent: number) => void,
signal?: AbortSignal
) =>
sendRaw<{ path: string; skipped?: boolean }>(
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
file,
onProgress,
signal
scrubImage(file).then((clean) =>
sendRaw<{ path: string; skipped?: boolean }>(
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
clean,
onProgress,
signal
)
),
/**
* {@link files.upload}, resumable: sent in parts and written once whole,
@@ -3452,11 +3525,13 @@ export const api = {
conflict: FsConflict,
opts: StagedSendOpts
) =>
sendStaged<{ path: string; skipped?: boolean }>(
t,
(staged) => fsUrl(t, "/upload", new URLSearchParams({ path, conflict, staged })),
file,
opts
scrubImage(file).then((clean) =>
sendStaged<{ path: string; skipped?: boolean }>(
t,
(staged) => fsUrl(t, "/upload", new URLSearchParams({ path, conflict, staged })),
clean,
opts
)
),
/** Forget a resumable upload's staged bytes (stopped, or given up). Never throws. */
dropUpload: (t: FsPlace, upload: string) =>
@@ -3476,11 +3551,13 @@ export const api = {
onProgress?: (sent: number) => void,
signal?: AbortSignal
) =>
sendRaw<{ saved: number; skipped: number; paths: string[] }>(
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
batchBody(files),
onProgress,
signal
scrubImages(files).then((clean) =>
sendRaw<{ saved: number; skipped: number; paths: string[] }>(
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
batchBody(clean),
onProgress,
signal
)
),
/** {@link files.uploadBatch}, resumable like {@link files.uploadStaged}. */
uploadBatchStaged: (
@@ -3490,11 +3567,13 @@ export const api = {
conflict: FsConflict,
opts: StagedSendOpts
) =>
sendStaged<{ saved: number; skipped: number; paths: string[] }>(
t,
(staged) => fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict, staged })),
batchBody(files),
opts
scrubImages(files).then((clean) =>
sendStaged<{ saved: number; skipped: number; paths: string[] }>(
t,
(staged) => fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict, staged })),
batchBody(clean),
opts
)
),
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
@@ -3688,9 +3767,9 @@ export const api = {
);
},
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
uploadFile: async (file: File, fields: { title?: string; description?: string } = {}) => {
const form = new FormData();
form.append("files", file, file.name);
form.append("files", await scrubImage(file), file.name);
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
},
@@ -3760,14 +3839,14 @@ export const api = {
if (path) qs.set("path", path);
if (download) qs.set("download", "1");
const s = qs.toString();
return `${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`;
return withToken(`${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`);
},
},
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
pets: {
list: () => request<PetList>("/pets"),
/** The pet's spritesheet (a Codex pet) or image, served raster-only. */
spriteUrl: (id: string) => `${BASE}/pets/${encodeURIComponent(id)}/sprite`,
spriteUrl: (id: string) => withToken(`${BASE}/pets/${encodeURIComponent(id)}/sprite`),
/** POST /api/pets/import — a `.codex-pet.zip` (pet.json + spritesheet.webp). */
importBundle: (file: File) =>
request<{ pet: PetView }>("/pets/import", {
@@ -3850,11 +3929,7 @@ export const api = {
job: () => request<AiToolJob | null>("/ai-tools/job"),
/** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */
/** The server-cached icon; an <img> cannot send the bearer header, so the token rides the query. */
iconUrl: (id: string) => {
const token = dashboardToken();
const q = token ? `?token=${encodeURIComponent(token)}` : "";
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
},
iconUrl: (id: string) => withToken(`${BASE}/ai-tools/${encodeURIComponent(id)}/icon`),
},
// ──────────────────────────────── AI Proxy API ──────────────────────────────
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
@@ -3888,14 +3963,22 @@ export const api = {
},
/** The hub's remote-client views (server mode, server/hub/). */
remoteHub: {
/** GET /api/remote/policy - may laptops connect to this hub at all? */
policy: () => request<{ allowDevices: boolean; changedAt: string | null }>("/remote/policy"),
/** PUT /api/remote/policy - an admin turns laptop connections on/off. */
setPolicy: (allowDevices: boolean) =>
request<{ allowDevices: boolean; changedAt: string | null }>("/remote/policy", {
method: "PUT",
body: JSON.stringify({ allowDevices }),
}),
/** GET /api/remote/devices - your machines; staff may name `user`. */
devices: (user?: number) =>
request<{ devices: RemoteDevice[] }>(`/remote/devices${user ? `?user=${user}` : ""}`),
/** POST /api/remote/devices/:id/revoke - your own password again. */
revoke: (id: number, password: string) =>
/** POST /api/remote/devices/:id/revoke - your own device, or (admin) anyone's. */
revoke: (id: number) =>
request<{ ok: true; device: RemoteDevice }>(`/remote/devices/${id}/revoke`, {
method: "POST",
body: JSON.stringify({ password }),
body: JSON.stringify({}),
}),
/** GET /api/remote/sessions - Company-account sessions (device › folder). */
sessions: (user?: number) =>
@@ -3967,9 +4050,12 @@ export const api = {
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its
* version, and this platform's upgrade advice when it is too old. */
tmuxAvailable: () =>
request<{ available: boolean; version?: string | null; upgrade?: string | null }>(
"/run/tmux"
),
request<{
available: boolean;
version?: string | null;
upgrade?: string | null;
upgradeSeverity?: "warning" | "error" | null;
}>("/run/tmux"),
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
@@ -3982,23 +4068,23 @@ export const api = {
},
/** POST /api/run/drop-image - save an image dropped on the terminal on the
* server's machine; the returned path is what gets pasted into the pane. */
dropImage: (file: Blob) =>
dropImage: async (file: Blob) =>
request<{ path: string }>("/run/drop-image", {
method: "POST",
body: file,
body: await scrubImage(file),
headers: { "Content-Type": file.type },
}),
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
* name so the agent sees what it is. `lane` (a workspace id, or a pane's
* run id) saves it where THAT workspace's panes can read it. */
dropFile: (file: File, lane?: number | string | null) => {
dropFile: async (file: File, lane?: number | string | null) => {
const laneId =
typeof lane === "string" ? /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(lane)?.[1] : lane;
const qs = new URLSearchParams({ name: file.name });
if (laneId != null) qs.set("laneId", String(laneId));
return request<{ path: string }>(`/run/drop-file?${qs.toString()}`, {
method: "POST",
body: file,
body: await scrubImage(file),
headers: { "Content-Type": "application/octet-stream" },
});
},
@@ -4204,7 +4290,7 @@ export const api = {
qs.delete("limit");
qs.delete("offset");
qs.set("format", format);
return `${BASE}/query?${qs.toString()}`;
return withToken(`${BASE}/query?${qs.toString()}`);
},
},
@@ -4226,7 +4312,7 @@ export const api = {
runs: (id: number) => request<{ runs: ReportRun[] }>(`/reports/${id}/runs`),
/** A run's file, as a download link. */
fileUrl: (id: number, runId: number, format: string) =>
`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`,
withToken(`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`),
},
// ───────────────────────────────── Webhooks API ─────────────────────────────
@@ -4788,20 +4874,20 @@ export const api = {
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
withToken(`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`),
/** GET /api/lanes/:id/git/download — one working file as an attachment
* (any type; a plain, non-git folder too). For an `<a download>`. */
gitDownloadUrl: (id: GitTarget, path: string) =>
`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`,
withToken(`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`),
/**
* POST /api/lanes/:id/git/upload — create `file` in the folder `dir`
* ("" = the workspace root). Only creates: a taken name answers 409
* EEXISTS; at most 25 MB (413 ETOOBIG).
*/
gitUpload: (id: GitTarget, dir: string, file: File) =>
gitUpload: async (id: GitTarget, dir: string, file: File) =>
request<{ path: string }>(gitUrl(id, "/upload", new URLSearchParams({ dir })), {
method: "POST",
body: file,
body: await scrubImage(file),
headers: {
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent(file.name),
@@ -5084,10 +5170,10 @@ export const api = {
body: JSON.stringify({ path }),
}),
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
addFile: (id: number, file: File) =>
addFile: async (id: number, file: File) =>
request<LaneSourcesView>(`/workspaces/${id}/sources/file`, {
method: "POST",
body: file,
body: await scrubImage(file),
headers: {
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent(file.name),
@@ -5095,6 +5181,31 @@ export const api = {
}),
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
* uploaded file's AgentHub copy is deleted. */
/** GET /api/lanes/:id/shares — the users a workspace is shared with (server mode). */
shares: (id: number) =>
request<{ shares: LaneShare[]; canManage: boolean }>(`/workspaces/${id}/shares`),
/** PUT /api/lanes/:id/shares — the WHOLE list (owner or admin; `ENOTOWNER` else). */
setShares: (id: number, userIds: number[]) =>
request<{ shares: LaneShare[] }>(`/workspaces/${id}/shares`, {
method: "PUT",
body: JSON.stringify({ userIds }),
}),
/** GET /api/lanes/:id/presence — who has which pane open (server mode). */
presence: (id: number) =>
request<{ panes: Record<string, { id: number; name: string }[]> }>(
`/workspaces/${id}/presence`
),
/** GET /api/lanes/:id/sessions/:sid/shares — the users one session is shared with. */
sessionShares: (id: number, sid: string) =>
request<{ shares: LaneShare[]; canManage: boolean }>(
`/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares`
),
/** PUT /api/lanes/:id/sessions/:sid/shares — the WHOLE list (owner or admin). */
setSessionShares: (id: number, sid: string, userIds: number[]) =>
request<{ shares: LaneShare[] }>(
`/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares`,
{ method: "PUT", body: JSON.stringify({ userIds }) }
),
removeSource: (id: number, sourceId: number) =>
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
@@ -6496,6 +6607,9 @@ export interface RunHandle {
startedAt: string | null;
/** Initial prompt preview (first 500 chars), null when not provided. */
promptPreview: string | null;
/** Server mode: a user the workspace is shared with opened it — they pay
* for it. Null/absent = the workspace's owner. */
startedBy?: { id: number; name: string } | null;
tool: LaneTool;
}
@@ -6763,6 +6877,8 @@ export interface BackupState {
dir: string;
hour: number;
keep: number;
/** Days between backups (1 = daily, 7 = weekly). */
every: number;
running: boolean;
last: {
at: string;
@@ -6774,7 +6890,7 @@ export interface BackupState {
} | null;
files: { name: string; size: number; mtime: string }[];
dir_error: string | null;
env_owned: { dir: boolean; hour: boolean; keep: boolean };
env_owned: { dir: boolean; hour: boolean; keep: boolean; every?: boolean };
}
/** GET /api/settings/retention. `days` 0 = automatic retention off;
+232
View File
@@ -0,0 +1,232 @@
/**
* @file imageMetadata.ts
* @description Takes the hidden metadata out of a photo before the browser
* uploads it: EXIF (GPS position, camera, date, owner), XMP, IPTC and
* comments in JPEG, PNG and WebP. Lossless — only metadata segments are cut,
* the pixels are never re-encoded. A JPEG keeps a minimal EXIF holding ONLY
* its orientation (so a phone photo is not shown sideways), and the data a
* phone appends after the image (extra pictures, motion-photo video) is cut
* off. Any other file — or an image too large or not parseable — is returned
* unchanged, so an upload never fails because of this step.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Larger images go up untouched rather than be read into memory whole. */
export const MAX_SCRUB_BYTES = 64 * 1024 * 1024;
const fourcc = (b: Uint8Array, at: number) =>
String.fromCharCode(b[at] ?? 0, b[at + 1] ?? 0, b[at + 2] ?? 0, b[at + 3] ?? 0);
const ascii = (b: Uint8Array, at: number, s: string) => {
if (at + s.length > b.length) return false;
for (let i = 0; i < s.length; i++) if (b[at + i] !== s.charCodeAt(i)) return false;
return true;
};
/** The EXIF orientation (1–8) in an APP1 "Exif\0\0" payload, or 0. */
function exifOrientation(seg: Uint8Array): number {
if (!ascii(seg, 0, "Exif\0\0")) return 0;
const t = 6;
if (seg.length < t + 8) return 0;
const le = ascii(seg, t, "II");
if (!le && !ascii(seg, t, "MM")) return 0;
const dv = new DataView(seg.buffer, seg.byteOffset, seg.byteLength);
const u16 = (o: number) => dv.getUint16(o, le);
const u32 = (o: number) => dv.getUint32(o, le);
const ifd = t + u32(t + 4);
if (ifd + 2 > seg.length) return 0;
const n = u16(ifd);
for (let i = 0; i < n; i++) {
const e = ifd + 2 + i * 12;
if (e + 12 > seg.length) return 0;
if (u16(e) === 0x0112) {
const v = u16(e + 8);
return v >= 1 && v <= 8 ? v : 0;
}
}
return 0;
}
/** An APP1 segment (marker included) carrying only `orientation`. */
function orientationApp1(orientation: number): Uint8Array {
const out = new Uint8Array(4 + 32);
const dv = new DataView(out.buffer);
out.set([0xff, 0xe1], 0);
dv.setUint16(2, 2 + 32); // the length counts itself, not the marker
out.set([0x45, 0x78, 0x69, 0x66, 0, 0], 4); // "Exif\0\0"
out.set([0x49, 0x49, 0x2a, 0], 10); // "II*\0"
dv.setUint32(14, 8, true); // IFD0 right after the header
dv.setUint16(18, 1, true); // one entry
dv.setUint16(20, 0x0112, true); // Orientation
dv.setUint16(22, 3, true); // SHORT
dv.setUint32(24, 1, true); // count
dv.setUint16(28, orientation, true);
dv.setUint32(32, 0, true); // no next IFD
return out;
}
/** JPEG: keep JFIF, ICC profile and Adobe; drop other APPn and comments. */
function scrubJpeg(b: Uint8Array): Uint8Array[] | null {
if (b[0] !== 0xff || b[1] !== 0xd8) return null;
const parts: Uint8Array[] = [b.subarray(0, 2)];
let orientation = 0;
let i = 2;
for (;;) {
if (i + 4 > b.length || b[i] !== 0xff) return null;
const m = b[i + 1] ?? 0;
if (m === 0xff) {
i++; // fill byte
continue;
}
if (m === 0xda) break; // start of scan: image data follows
if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) {
parts.push(b.subarray(i, i + 2));
i += 2;
continue;
}
const len = ((b[i + 2] ?? 0) << 8) | (b[i + 3] ?? 0);
if (len < 2 || i + 2 + len > b.length) return null;
const seg = b.subarray(i, i + 2 + len);
const body = b.subarray(i + 4, i + 2 + len);
const isApp = m >= 0xe0 && m <= 0xef;
const keep =
!isApp && m !== 0xfe
? true
: m === 0xe0
? ascii(body, 0, "JFIF\0") || ascii(body, 0, "JFXX\0")
: m === 0xe2
? ascii(body, 0, "ICC_PROFILE\0")
: m === 0xee
? ascii(body, 0, "Adobe")
: false;
if (m === 0xe1 && !orientation) orientation = exifOrientation(body);
if (keep) parts.push(seg);
i += 2 + len;
}
// The image data runs to the first EOI; anything after it (a phone's extra
// pictures or a motion-photo video, each with its own metadata) is dropped.
let end = b.length;
for (let j = i + 2; j + 1 < b.length; j++) {
if (b[j] === 0xff && b[j + 1] === 0xd9) {
end = j + 2;
break;
}
}
if (orientation > 1) parts.splice(1, 0, orientationApp1(orientation));
parts.push(b.subarray(i, end));
return parts;
}
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const PNG_DROP = new Set(["eXIf", "tEXt", "zTXt", "iTXt", "tIME"]);
/** PNG: drop the text, time and EXIF chunks; stop at IEND. */
function scrubPng(b: Uint8Array): Uint8Array[] | null {
if (!PNG_SIG.every((v, k) => b[k] === v)) return null;
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
const parts: Uint8Array[] = [b.subarray(0, 8)];
let i = 8;
while (i + 12 <= b.length) {
const len = dv.getUint32(i);
const type = fourcc(b, i + 4);
const next = i + 12 + len;
if (next > b.length) return null;
if (!PNG_DROP.has(type)) parts.push(b.subarray(i, next));
i = next;
if (type === "IEND") return parts;
}
return null;
}
/** WebP: drop the EXIF and XMP chunks and their VP8X flags. */
function scrubWebp(b: Uint8Array): Uint8Array[] | null {
if (!ascii(b, 0, "RIFF") || !ascii(b, 8, "WEBP")) return null;
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
const chunks: Uint8Array[] = [];
let i = 12;
const riffEnd = Math.min(b.length, 8 + dv.getUint32(4, true));
while (i + 8 <= riffEnd) {
const tag = fourcc(b, i);
const size = dv.getUint32(i + 4, true);
const next = i + 8 + size + (size & 1);
if (i + 8 + size > b.length) return null;
if (tag === "VP8X") {
const c = b.slice(i, Math.min(next, b.length));
c[8] = (c[8] ?? 0) & ~(0x08 | 0x04); // EXIF and XMP present
chunks.push(c);
} else if (tag !== "EXIF" && tag !== "XMP ") {
chunks.push(b.subarray(i, Math.min(next, b.length)));
}
i = next;
}
const body = chunks.reduce((n, c) => n + c.length, 0);
const head = new Uint8Array(12);
head.set(b.subarray(0, 12));
new DataView(head.buffer).setUint32(4, 4 + body, true);
return [head, ...chunks];
}
/** The bytes of `b` without metadata, or null when there is nothing to change. */
export function scrubImageBytes(b: Uint8Array): Uint8Array<ArrayBuffer> | null {
let parts: Uint8Array[] | null = null;
try {
parts = scrubJpeg(b) ?? scrubPng(b) ?? scrubWebp(b);
} catch {
parts = null;
}
if (!parts) return null;
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(new ArrayBuffer(total));
let at = 0;
for (const p of parts) {
out.set(p, at);
at += p.length;
}
if (out.length === b.length && out.every((v, k) => v === b[k])) return null;
return out;
}
const LOOKS_LIKE_IMAGE = /^image\/(jpeg|pjpeg|png|webp)$/i;
const IMAGE_NAME = /\.(jpe?g|jfif|png|webp)$/i;
async function bytesOf(blob: Blob): Promise<Uint8Array> {
if (typeof blob.arrayBuffer === "function") return new Uint8Array(await blob.arrayBuffer());
// jsdom's Blob has no arrayBuffer(); FileReader reads it everywhere.
return new Promise((ok, no) => {
const r = new FileReader();
r.onload = () => ok(new Uint8Array(r.result as ArrayBuffer));
r.onerror = () => no(r.error);
r.readAsArrayBuffer(blob);
});
}
/**
* `blob` without its photo metadata — a File keeps its name, type and date.
* Returns the very same object when it is no JPEG/PNG/WebP, is larger than
* {@link MAX_SCRUB_BYTES}, carries no metadata, or cannot be read.
*/
export async function scrubImage<T extends Blob>(blob: T): Promise<T> {
const name = typeof File !== "undefined" && blob instanceof File ? blob.name : "";
if (!LOOKS_LIKE_IMAGE.test(blob.type || "") && !IMAGE_NAME.test(name)) return blob;
if (blob.size === 0 || blob.size > MAX_SCRUB_BYTES) return blob;
try {
const clean = scrubImageBytes(await bytesOf(blob));
if (!clean) return blob;
if (typeof File !== "undefined" && blob instanceof File) {
return new File([clean], blob.name, {
type: blob.type,
lastModified: blob.lastModified,
}) as unknown as T;
}
return new Blob([clean], { type: blob.type }) as unknown as T;
} catch {
return blob;
}
}
/** {@link scrubImage} for each `{file}` of a batch, in order. */
export async function scrubImages<F extends { file: Blob }>(files: F[]): Promise<F[]> {
const out: F[] = [];
for (const f of files) out.push({ ...f, file: await scrubImage(f.file) });
return out;
}
+2 -1
View File
@@ -9,6 +9,7 @@
*/
import type { NotifSound } from "./notifPrefs";
import { tokenHeader } from "./api";
const audio = new Map<NotifSound, HTMLAudioElement>();
@@ -63,7 +64,7 @@ export async function notify(title: string, body: string, url?: string): Promise
try {
const res = await fetch("/api/push/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...tokenHeader() },
body: JSON.stringify({ title, body, url }),
});
const result = res.ok ? await res.json().catch(() => null) : null;
+7 -2
View File
@@ -17,7 +17,7 @@
*/
import type { DragEvent } from "react";
import { api } from "./api";
import { api, withToken } from "./api";
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
export interface Attachment {
@@ -331,7 +331,12 @@ function readRecent(): Recent[] {
}
export function recentUploads(): Attachment[] {
return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) }));
return readRecent().map(({ path, name, url }) => ({
path,
name,
// Stored without the token; added on the way out.
...(url ? { url: withToken(url) } : {}),
}));
}
/** Remember uploads just saved, newest first; an image keeps a thumbnail
+6 -3
View File
@@ -23,6 +23,9 @@
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { tokenHeader } from "./api";
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
@@ -138,7 +141,7 @@ export async function subscribeToPush(): Promise<void> {
if (existing) return; // already subscribed -> idempotent no-op
// Fetch the server's VAPID public key and decode it for `applicationServerKey`.
const res = await fetch("/api/push/vapid-public-key");
const res = await fetch("/api/push/vapid-public-key", { headers: tokenHeader() });
const { publicKey } = await res.json();
const subscription = await registration.pushManager.subscribe({
@@ -149,7 +152,7 @@ export async function subscribeToPush(): Promise<void> {
// Register the endpoint + keys with the backend so it can push to this browser.
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...tokenHeader() },
body: JSON.stringify(subscription.toJSON()), // { endpoint, keys: { p256dh, auth } }
});
}
@@ -179,7 +182,7 @@ export async function unsubscribeFromPush(): Promise<void> {
// Tell the backend to forget this endpoint so it stops attempting deliveries.
await fetch("/api/push/subscribe", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...tokenHeader() },
body: JSON.stringify({ endpoint }),
});
}
+10 -6
View File
@@ -36,6 +36,8 @@ export const SETTINGS_INDEX: {
adminOnly?: boolean;
/** Hidden in local mode (AgentHub's own git logins), so kept out of its search. */
serverOnly?: boolean;
/** Server mode: moved to Administration → Server settings, so out of Settings' search. */
serverAdmin?: boolean;
}[] = [
{
section: "account",
@@ -162,6 +164,7 @@ export const SETTINGS_INDEX: {
{
section: "system",
titleKey: "autostart.label",
serverAdmin: true,
adminOnly: true,
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
to: "#autostart",
@@ -169,6 +172,7 @@ export const SETTINGS_INDEX: {
{
section: "system",
titleKey: "quit.label",
serverAdmin: true,
adminOnly: true,
keywords: ["quit", "stop", "kill", "shutdown", "exit", "background"],
to: "#autostart",
@@ -176,6 +180,7 @@ export const SETTINGS_INDEX: {
{
section: "system",
titleKey: "tray.label",
serverAdmin: true,
adminOnly: true,
keywords: ["tray", "icon", "taskbar", "menu bar", "status area", "notification area"],
to: "#autostart",
@@ -205,6 +210,7 @@ export const SETTINGS_INDEX: {
{
section: "reports",
titleKey: "reports.title",
serverAdmin: true,
adminOnly: true,
descKey: "reports.description",
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
@@ -254,6 +260,7 @@ export const SETTINGS_INDEX: {
{
section: "agents",
titleKey: "artifacts:installsSwitch",
serverAdmin: true,
descKey: "artifacts:installsSwitchHint",
keywords: ["artifacts", "hub", "skill", "plugin", "cài đặt"],
to: "#artifacts-hub",
@@ -324,17 +331,13 @@ export const SETTINGS_INDEX: {
"codex",
"omp",
"pool",
"pool account",
"automatic",
"hồ sơ",
"tài khoản",
],
to: "#tool-profiles",
},
{
section: "agents",
titleKey: "poolAccount.title",
keywords: ["pool", "account", "switch", "quota", "tài khoản", "pool account"],
to: "#pool-account",
},
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
{ section: "agents/codex", titleKey: "codexHooks.title" },
{ section: "agents/codex", titleKey: "codexHome.title" },
@@ -362,6 +365,7 @@ export const SETTINGS_INDEX: {
{
section: "git",
titleKey: "gitCreds.title",
serverAdmin: true,
adminOnly: true,
serverOnly: true,
keywords: ["login", "token"],
+7 -3
View File
@@ -2,12 +2,13 @@
* @file toast.ts
* @description App-wide toast queue: `toast.success(msg)` / `toast.error(msg)`
* from anywhere, rendered once by `components/Toaster.tsx` inside live regions.
* A success fades after a few seconds; an error stays until dismissed, because
* A success fades after a few seconds; an error — and an amber `warning`, for
* advice that is worth reading but is no failure — stays until dismissed, because
* a message that vanishes before it is read is no message at all (WCAG 2.2.1).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export type ToastKind = "success" | "error" | "info";
export type ToastKind = "success" | "error" | "info" | "warning";
export interface ToastItem {
id: number;
@@ -43,7 +44,8 @@ function push(kind: ToastKind, message: string): number {
}
items = next;
emit();
if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS);
// An error or a warning stays until closed, so its advice can be copied.
if (kind !== "error" && kind !== "warning") window.setTimeout(() => dismissToast(id), SUCCESS_MS);
return id;
}
@@ -59,6 +61,8 @@ export function subscribeToasts(fn: (items: ToastItem[]) => void): () => void {
export const toast = {
success: (message: string) => push("success", message),
info: (message: string) => push("info", message),
/** Amber and sticky: worth reading, not a failure. */
warning: (message: string) => push("warning", message),
error: (message: unknown) =>
push("error", message instanceof Error ? message.message : String(message)),
};
+6
View File
@@ -1942,6 +1942,10 @@ export interface WSMessage {
| "remote_source.status"
| "remote_data.updated"
| "lane_update"
/** A workspace was shared with / taken back from the users named (`userIds`). */
| "lane_access"
/** Who has a pane open changed: `{laneId, runId, users}` (pane-presence.js). */
| "pane_presence"
/** One output line from a lane's profile hook, while it runs. */
| "lane_hook_output"
/** A lane's stack finished coming up (or failed to), with fresh facts. */
@@ -2706,6 +2710,8 @@ export interface Lane {
owner_display_name?: string | null;
/** When the owner's avatar was last set (`users.avatar_at`); null = none. */
owner_avatar_at?: string | null;
/** Server mode: `"session"` when only sessions of it are shared with this viewer. */
access?: "session";
branch: string | null;
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
+4
View File
@@ -73,6 +73,7 @@ import "@fontsource/maple-mono/700.css";
import App from "./App";
import { AuthGate } from "./components/AuthGate";
import { i18nReady } from "./i18n";
import { adoptTokenFromUrl } from "./lib/api";
import "./index.css";
// Deep link into the Workspace: start its (lazy) chunk now, in parallel with the
@@ -103,6 +104,9 @@ if ("serviceWorker" in navigator) {
.catch(() => {});
}
// `agenthub start` prints a link carrying `?token=`: keep it, drop it from the URL.
adoptTokenFromUrl();
const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");
+26 -5
View File
@@ -1,8 +1,10 @@
/**
* @file Administration.tsx
* @description The Administration page (`/admin`, a sidebar item; `/users`
* redirects here), server mode only and only for an admin or analyst — ONE
* page, no tabs: ONE users table holding every per-user account fact (account,
* redirects here), server mode only and only for an admin or analyst. Two
* tabs (`?tab=`): **Users** for staff, and **Server settings** for an admin —
* every setting that acts on the server for everyone, moved out of Settings
* (`components/admin/ServerSettings.tsx`). Users is ONE users table holding every per-user account fact (account,
* spend, home, tmux; each row expands in
* place into that user's detail), the account pool, then the audit log — all
* from `Users.tsx`. The machine and each user's live CPU / RAM live on the
@@ -10,10 +12,12 @@
* Users page always did.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Navigate } from "react-router-dom";
import { Navigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ShieldCheck } from "lucide-react";
import { ServerCog, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { useAuth } from "../components/AuthGate";
import { PageTabs } from "../components/PageTabs";
import { ServerSettings } from "../components/admin/ServerSettings";
import { useHostStats } from "../components/health/HostHealth";
import { Users } from "./Users";
@@ -25,8 +29,11 @@ export function Administration() {
// Read once, for each home's share of the homes disk. The machine and each
// user's live CPU / RAM are on the Dashboard's System Health tab.
const host = useHostStats(staff, 0);
const [params, setParams] = useSearchParams();
const isAdmin = me?.role === "admin";
if (!staff) return <Navigate to="/" replace />;
const tab = isAdmin && params.get("tab") === "server" ? "server" : "users";
return (
<div className="animate-fade-in space-y-6">
@@ -39,7 +46,21 @@ export function Administration() {
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
<Users embedded host={host} />
{isAdmin ? (
<PageTabs
ariaLabel={t("title")}
activeId={tab}
onSelect={(id) => setParams(id === "server" ? { tab: "server" } : {}, { replace: true })}
tabs={[
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
{ id: "server", label: t("tabs.server"), Icon: ServerCog },
]}
>
{tab === "server" ? <ServerSettings /> : <Users embedded host={host} />}
</PageTabs>
) : (
<Users embedded host={host} />
)}
</div>
);
}
+5 -4
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the scheduled database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -198,7 +198,7 @@ export function Settings({
section?: "data" | "about" | "notifications";
} = {}) {
const { t } = useTranslation("settings");
const { isAdmin, seesAll } = usePermissions();
const { isAdmin, seesAll, local } = usePermissions();
// Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo);
@@ -1123,8 +1123,9 @@ export function Settings({
{section === "about" && (
<div id="about" className="space-y-6 scroll-mt-24">
{/* Start at login, the icon, Quit and Uninstall act on the host: an admin's. */}
{isAdmin && <AutostartSettings />}
{/* Start at login, the icon, Quit and Uninstall act on the host: an
admin's (server mode: Administration → Server settings). */}
{isAdmin && local && <AutostartSettings />}
{sysInfo ? (
<SystemDetails info={sysInfo} />
) : (
+44 -57
View File
@@ -27,6 +27,12 @@
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
* while the tab is hidden behind a session.
* Server mode moves every setting that acts on the whole server — laptop
* connections, members' test runs, omp sharing, Artifacts installs, the
* host's git logins, scheduled reports, start at login, Pricing and Data — to
* Administration → Server settings (`components/admin/ServerSettings.tsx`);
* `/settings/data` and `/settings/pricing` redirect there, and an admin gets
* a link to it under the nav. Local mode keeps them all here.
* Server mode hides every tool page from a non-admin, except Claude Code for a
* non-admin with their own Linux account (`useCcTarget`): "Your Claude Code
* config", which they manage as themselves.
@@ -62,7 +68,6 @@ import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiProxyConfig } from "./AiProxyConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { GitIdentitySettings } from "../components/settings/GitIdentitySettings";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AccountSettings } from "../components/settings/AccountSettings";
@@ -71,13 +76,9 @@ import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
import { CompanyHubSettings } from "../components/settings/CompanyHubSettings";
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { AllPricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
import { useAiTools } from "../hooks/useAiTools";
@@ -255,7 +256,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
};
}, [location.key, location.hash]);
const { isAdmin, local, seesAll } = usePermissions();
const { isAdmin, local } = usePermissions();
const authState = useAuth().state;
const signedIn = authState.mode === "server" && !!authState.user;
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
@@ -310,19 +311,15 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<AgentInstructionsSettings />
<SessionDefaultsSettings />
<AiToolsSettings settingsHref={toolHref} />
{/* Every AI tool's login profiles, linked to the account pool: the
host's logins, a staff-only read (a developer would get 403s). */}
{seesAll && <ToolProfilesSettings />}
{/* Every user's own pick of a pool account (server mode; hides itself). */}
<PoolAccountSettings />
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings />
{/* The host's omp login for members: server mode + S3, an admin's. */}
<OmpSharingSettings />
{/* Members' test runs on this box: server mode + S3, an admin's. */}
<MemberPolicySettings />
{/* The company hub: enroll this machine (local) / your machines (hub). */}
<CompanyHubSettings />
{/* Every AI tool's login profiles with the pool accounts the user may
run on as rows of the same list: staff see the host's logins too,
a developer only the pool rows (it hides itself with none). */}
<ToolProfilesSettings />
{/* The Hub's install switch, omp sharing and members' test runs are
server-mode admin settings: Administration → Server settings. */}
{/* The company hub: enroll this machine (local) / your own machines
(a non-admin on the hub; an admin's is in Administration). */}
{(local || !isAdmin) && <CompanyHubSettings />}
</div>
),
},
@@ -382,26 +379,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
group: "data",
Icon: Coins,
...s("pricing"),
render: () => (
<div className="space-y-6">
<PricingSettings
vendor="claude"
allowReset
id="pricing-claude"
heading={t("pricing.vendor.claude")}
/>
<PricingSettings
vendor="openai"
id="pricing-openai"
heading={t("pricing.vendor.openai")}
/>
<PricingSettings
vendor="google"
id="pricing-google"
heading={t("pricing.vendor.google")}
/>
</div>
),
render: () => <AllPricingSettings />,
},
{
id: "reports",
@@ -413,8 +391,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<div id="snapshots" className="scroll-mt-24">
<SnapshotSettings />
</div>
{/* Scheduled reports read every user's sessions: an admin's. */}
{isAdmin && <ReportSettings />}
{/* Scheduled reports read every user's sessions: an admin's (server
mode: Administration → Server settings). */}
{isAdmin && local && <ReportSettings />}
</div>
),
},
@@ -425,15 +404,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
...s("git"),
render: () => (
<div className="space-y-6">
{/* Git logins are the host's own: an admin's. Not in local mode,
nor the identity below: git uses this machine's own config,
SSH keys and credential helper there. */}
{isAdmin && !local && (
<section id="git-credentials" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
<GitCredentialsConfig />
</section>
)}
{/* The host's git logins are an admin's: Administration → Server
settings. Not in local mode, nor the identity below: git uses
this machine's own config, SSH keys and credential helper there. */}
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
{!local && (isAdmin || signedIn) && (
<section id="git-identity" className="scroll-mt-24">
@@ -455,12 +428,14 @@ export function SettingsHub({ section }: { section?: string } = {}) {
];
// Server mode: only an admin changes the host's own configuration (the
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
const sections = isAdmin
? allSections
: allSections.filter(
(x) =>
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
);
const sections = (
isAdmin
? allSections
: allSections.filter(
(x) =>
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
)
).filter((x) => local || !ADMIN_SECTIONS.has(x.id)); // server mode: in Administration
const navSections = sections.filter((x) => !x.tool);
const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) {
@@ -474,6 +449,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
(e) =>
(isAdmin || !e.adminOnly) &&
(!local || !e.serverOnly) &&
(local || !e.serverAdmin) &&
(e.section === sec.id ||
(e.section === "agents/*" &&
sec.id.startsWith("agents/") &&
@@ -502,6 +478,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
return <Navigate to={href(to)} replace />;
}
if (MOVED[rest]) return <Navigate to={href(MOVED[rest])} replace />;
// Server mode: the server-wide pages live in Administration → Server settings.
if (!local && ADMIN_SECTIONS.has(rest))
return <Navigate to={`/admin?tab=server#${rest}`} replace />;
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool page exists only once the tool list has loaded.
@@ -629,6 +608,14 @@ export function SettingsHub({ section }: { section?: string } = {}) {
);
})}
</nav>
{isAdmin && !local && (
<p className="mt-5 px-2 text-[11px] leading-relaxed text-fg-muted">
{t("settingsNav.movedToAdmin")}{" "}
<Link to="/admin?tab=server" className="text-accent hover:underline">
{t("admin:tabs.server")}
</Link>
</p>
)}
</aside>
<div className="min-w-0 max-w-5xl flex-1 pb-10">
+5 -16
View File
@@ -20,8 +20,8 @@
* on (`AccountPoolCard`). Each row's spend shows its spend quota (spent of the
* limit, red once used up) and the default quota sits in its own card
* (`UserQuota.tsx`); a user's own quota is part of Edit.
* Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display
* Only an admin changes anything (the server checks the role; no password
* again since 2026-11-09); an analyst only reads. A row shows the display
* name and email under the username when set, and "New password due" while
* the account still has a password an admin gave; right-clicking a row offers
* Edit, Open home (in /files), and copying the username or email. Clicking a
@@ -665,7 +665,7 @@ function RowAction({
);
}
/** A new account: username, password, role, then the admin's own password. */
/** A new account: username, password, role. */
function AddUserDialog({
onClose,
onDone,
@@ -677,18 +677,17 @@ function AddUserDialog({
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("developer");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const ready = current.length > 0 && username.trim().length > 0 && password.length > 0;
const ready = username.trim().length > 0 && password.length > 0;
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
const r = await api.users.create({ username: username.trim(), password, role }, current);
const r = await api.users.create({ username: username.trim(), password, role });
onDone(t("done.created", { name: r.user.displayName || r.user.username }));
} catch (e) {
const err = e as Error & { code?: string };
@@ -755,16 +754,6 @@ function AddUserDialog({
))}
</select>
</Field>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
+81 -47
View File
@@ -71,7 +71,7 @@ import {
ChevronRight,
GitBranch,
} from "lucide-react";
import { api, type GitSource } from "../lib/api";
import { api, withToken, type GitSource } from "../lib/api";
import { useInnerReposMode } from "../lib/innerReposPref";
import { usePermissions } from "../components/AuthGate";
import type {
@@ -661,16 +661,29 @@ export function Workspace() {
const { local } = usePermissions();
const [searchParams, setSearchParams] = useSearchParams();
// The server's tmux is older than this platform needs (psmux < 3.4.0 on
// Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is.
// Sticky (an error toast) so the command can be copied. An older server or
// no answer says nothing.
// The server's tmux is older than this platform needs: say how to upgrade
// it. An old psmux (Windows) stalls terminals — an error, every visit. An old
// tmux only lacks the pane scrollbar — an amber warning, shown once per
// version in this browser (closing it is for good). Both sticky, so the
// command can be copied. An older server or no answer says nothing.
useEffect(() => {
let live = true;
(async () => {
try {
const { upgrade } = await api.run.tmuxAvailable();
if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
const { upgrade, upgradeSeverity } = await api.run.tmuxAvailable();
if (!live || !upgrade) return;
if (upgradeSeverity === "warning") {
const SEEN = "agenthub.tmuxUpgrade.seen";
try {
if (localStorage.getItem(SEEN) === upgrade) return;
localStorage.setItem(SEEN, upgrade);
} catch {
/* storage blocked: show it */
}
toast.warning(tLanes("tmuxUpgradeOptional", { advice: upgrade }));
return;
}
toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
} catch {
/* nothing to say */
}
@@ -1002,8 +1015,11 @@ export function Workspace() {
const showRoot = !inner || inner.root || repos.length === 0;
// A file the chat asks for lies in the inner repo whose path prefixes it.
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
// Sections folded shut ("" = the workspace folder, else the folder's id).
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
// The stacked sections are an accordion: ONE is open at a time ("" = the
// workspace folder, `repo:<path>` an inner repo, else the folder's id).
// `undefined` = the first section (also when the open one is gone); `null`
// = every section folded.
const [openSource, setOpenSource] = useState<string | null | undefined>(undefined);
// Right-click on a section: close extra folders — which REMOVES them from the
// workspace (the folder stays on disk). The workspace folder never closes.
const sourceCtx = useContextMenu<string>();
@@ -1062,11 +1078,7 @@ export function Workspace() {
// Its path is the workspace folder's: unfold that section (or the
// inner repo's that holds it).
const repo = repoOfRef.current(path);
setFoldedSources((prev) => {
const next = new Set(prev);
next.delete(repo ? `repo:${repo.path}` : "");
return next;
});
setOpenSource(repo ? `repo:${repo.path}` : "");
setChangesOpen(true);
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
};
@@ -1270,8 +1282,12 @@ export function Workspace() {
// editor: no page padding, no cards, dividers between its regions.
const sessionView = !!currentLane && !home;
previewHostRef.current = sessionView;
// The home is locked to the viewport too: the Changes panel sits beside it
// at full height, and the home scrolls inside its own column.
const viewportLocked =
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
sessionView ||
(home && !!currentLane) ||
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -1345,27 +1361,41 @@ export function Workspace() {
it rather than under it. It collapses to a rail instead of
unmounting nothing — there is no live subscription inside it, so
unmounting is safe and keeps a closed panel from polling. */}
{home && currentLane && (
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
sourcesVersion={sourcesVersion}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
} else if (action === "reset" || action === "remove") {
setDestructive({ lane: currentLane, action });
}
}}
/>
)}
<div ref={changesRowRef} className={`relative flex min-h-0 flex-1${home ? " hidden" : ""}`}>
<div ref={changesRowRef} className="relative flex min-h-0 flex-1">
{/* The home shares the row with the Changes panel, like the session
view's console does — so a workspace's changes show before any
session is open. */}
{home && currentLane && (
<div
data-testid="workspace-home-column"
// `relative`: the home's absolutely placed bits (a row's sr-only
// label) must be clipped by THIS scroller — placed against the
// row outside it they stretched the page into an empty scroll.
// No visible scrollbar: the wheel and touch still scroll it.
className={`no-scrollbar relative min-h-0 min-w-0 flex-1 overflow-y-auto${
changesOpen && changesFull ? " hidden" : ""
}${changesOpen && !changesFull ? " pr-5" : ""}`}
>
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
sourcesVersion={sourcesVersion}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
} else if (action === "reset" || action === "remove") {
setDestructive({ lane: currentLane, action });
}
}}
/>
</div>
)}
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col${
currentLane && changesOpen && changesFull ? " hidden" : ""
home || (currentLane && changesOpen && changesFull) ? " hidden" : ""
}`}
>
{/* No header row: the console's tab bar is the header. Only a
@@ -1460,7 +1490,9 @@ export function Workspace() {
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
href={withToken(
`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`
)}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
@@ -1792,10 +1824,17 @@ export function Workspace() {
// One repo only: the one panel, as it always was.
if (sections.length === 1) return panel;
// Else every repo is a section, stacked like VS Code's
// Source Control; the open ones share the height. A
// Source Control — an accordion: opening one folds the
// others, so the open one has the whole height. A
// folded one stays mounted (CSS), so an unsaved edit in
// it survives.
const folded = foldedSources.has(key);
const openKey =
openSource === null
? null
: sections.some((x) => x.key === openSource)
? openSource
: sections[0]!.key;
const folded = key !== openKey;
return (
<section
key={key}
@@ -1811,13 +1850,7 @@ export function Workspace() {
type="button"
aria-expanded={!folded}
onContextMenu={(e) => sourceCtx.open(e, key)}
onClick={() =>
setFoldedSources((prev) => {
const next = new Set(prev);
if (!next.delete(key)) next.add(key);
return next;
})
}
onClick={() => setOpenSource(folded ? key : null)}
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
{folded ? (
@@ -1884,12 +1917,13 @@ export function Workspace() {
</MenuItem>
</ContextMenu>
)}
{/* The workspace's context — the home's right column, compact. */}
{/* The workspace's context — the home's right column, compact
(not on the home itself, which shows that column). */}
<section
data-testid="panel-workspace-context"
className={`-mx-3 flex shrink-0 flex-col border-t border-border px-3 ${
contextOpen ? "max-h-[45%]" : ""
}`}
className={`-mx-3 shrink-0 flex-col border-t border-border px-3 ${
home ? "hidden" : "flex"
} ${contextOpen ? "max-h-[45%]" : ""}`}
>
<button
type="button"
@@ -1,7 +1,8 @@
/**
* @file Administration.test.tsx
* @description The Administration page (`/admin`): local mode and a developer
* are sent home; staff see ONE page, no tabs, and no machine section (that is
* are sent home; an analyst sees the users with no tabs, an admin a Users tab
* and a Server settings tab (`?tab=server`); no machine section (that is
* the Dashboard's System Health tab now, each user's live CPU / RAM included)
* — the users table carries each user's home share (`GET /api/host`) and tmux.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -97,6 +98,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
};
});
// The server-settings blocks have their own tests (ServerSettings.test.tsx).
vi.mock("../../components/admin/ServerSettings", () => ({
ServerSettings: () => <div data-testid="server-settings" />,
}));
import { Administration } from "../Administration";
const renderAt = (url: string) =>
@@ -154,13 +160,33 @@ describe("Administration", () => {
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
});
it("shows the users on one page, with no tabs and no machine section", async () => {
it("shows an analyst the users with no tabs and no machine section", async () => {
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
renderAt("/admin?tab=server");
expect(await screen.findByTestId("users-table")).toBeTruthy();
expect(screen.queryByTestId("server-facts")).toBeNull();
expect(screen.queryByRole("tab", { name: /server settings/i })).toBeNull();
expect(screen.queryByTestId("server-settings")).toBeNull();
});
it("gives an admin a Users tab and a Server settings tab", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin");
expect(await screen.findByTestId("users-table")).toBeTruthy();
expect(screen.queryByTestId("server-facts")).toBeNull();
expect(screen.queryByRole("tab", { name: "Server" })).toBeNull();
expect(screen.queryByRole("tab", { name: "Users" })).toBeNull();
const server = screen.getByRole("tab", { name: /server settings|cài đặt máy chủ/i });
expect(screen.getByRole("tab", { name: /^(users|người dùng)$/i })).toHaveAttribute(
"aria-selected",
"true"
);
server.click();
expect(await screen.findByTestId("server-settings")).toBeTruthy();
expect(screen.queryByTestId("users-table")).toBeNull();
});
it("opens Server settings straight from ?tab=server", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin?tab=server");
expect(await screen.findByTestId("server-settings")).toBeTruthy();
});
it("puts each user's home and tmux in the users table, but not their CPU", async () => {
@@ -71,6 +71,13 @@ vi.mock("../../components/settings/ArtifactsHubSettings", () => ({
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
AllPricingSettings: () => (
<>
{["claude", "openai", "google"].map((v) => (
<div key={v} data-testid={`pricing-${v}`} />
))}
</>
),
}));
const tool = (id: string, over: Partial<AiTool> = {}) =>
@@ -347,13 +354,28 @@ describe("SettingsHub", () => {
expect(links).not.toContain("Pet");
});
it("shows git logins and worktree suggestions on one Git page", async () => {
it("server mode: the Git page keeps the user's own identity; the host's logins are in Administration", async () => {
perms.local = false;
renderAt("/settings/git");
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
expect(screen.queryByTestId("git-credentials")).toBeNull();
expect(screen.getByTestId("git-identity")).toBeInTheDocument();
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /server settings/i })).toHaveAttribute(
"href",
"/admin?tab=server"
);
});
it("server mode: Data and Pricing leave Settings for Administration → Server settings", async () => {
perms.local = false;
renderAt("/settings/appearance");
await screen.findByRole("heading", { level: 1, name: "Appearance" });
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links).not.toContain("Data");
expect(links).not.toContain("Pricing");
});
it("keeps AgentHub's own git logins and identity off a local Git page", async () => {
+20 -18
View File
@@ -1,9 +1,10 @@
/**
* @file Users.test.tsx
* @description The Users page: local mode and a member are sent home; an
* analyst reads the table with no account controls; an admin adds a user only
* with their own password typed (the confirm stays disabled until then), sees
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
* analyst reads the table with no account controls; an admin adds a user with
* no password of their own asked (the confirm waits only for a username and a
* password), sees a server refusal reported in the edit dialog, which stays
* open, opens ONE Edit per row (no
* separate role/password/workspace/disable buttons any more), and sees the
* display name and email under a username; right-clicking a row opens that
* user's home in Files (an admin) — an analyst's menu only copies; clicking a
@@ -216,7 +217,7 @@ describe("Users page", () => {
expect(screen.getByText("Analysts can view accounts but not change them.")).toBeTruthy();
});
it("adds a user only with the admin's own password", async () => {
it("adds a user once a username and password are typed, asking for no admin password", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.create.mockResolvedValue({
user: { id: 9, username: "neo", role: "developer", disabled: false },
@@ -225,44 +226,45 @@ describe("Users page", () => {
renderPage();
await ue.click(await screen.findByRole("button", { name: "Add user" }));
const dialog = await screen.findByRole("dialog");
await ue.type(screen.getByLabelText("Username"), "neo");
await ue.type(screen.getByLabelText("Password"), "neo-pass-123");
const confirm = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Add user"
)!;
expect(confirm.hasAttribute("disabled")).toBe(true);
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
expect(screen.queryByLabelText("Your password")).toBeNull();
await ue.type(screen.getByLabelText("Username"), "neo");
expect(confirm.hasAttribute("disabled")).toBe(true); // no password yet
await ue.type(screen.getByLabelText("Password"), "neo-pass-123");
expect(confirm.hasAttribute("disabled")).toBe(false);
// The pool board reads the user list too; count the page's own reload.
const before = users.list.mock.calls.length;
await ue.click(confirm);
await waitFor(() =>
expect(users.create).toHaveBeenCalledWith(
{ username: "neo", password: "neo-pass-123", role: "developer" },
"boss-pass"
)
expect(users.create).toHaveBeenCalledWith({
username: "neo",
password: "neo-pass-123",
role: "developer",
})
);
expect(users.create.mock.calls[0]).toHaveLength(1);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(users.list).toHaveBeenCalledTimes(before + 1); // reloaded after the change
});
it("reports a wrong password in the edit dialog and keeps it open", async () => {
it("reports a server refusal in the edit dialog and keeps it open", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.update.mockRejectedValue(
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
Object.assign(new Error("that is the last admin"), { code: "ELASTADMIN" })
);
const ue = userEvent.setup();
renderPage();
await screen.findByText("mia");
await ue.click(screen.getAllByRole("button", { name: "Edit user" })[1]!);
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1{Enter}");
expect(await screen.findByRole("alert")).toHaveProperty(
"textContent",
"Your password is wrong."
"The last admin cannot be deleted, demoted or disabled."
);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(users.update).toHaveBeenCalledWith(2, { password: "mia-new-pass-1" }, "wrong");
expect(users.update).toHaveBeenCalledWith(2, { password: "mia-new-pass-1" });
});
it("offers one Edit per row and no separate account buttons", async () => {
@@ -196,17 +196,20 @@ describe("Workspace — the Changes panel's width", () => {
expect(size()).toHaveAttribute("aria-label", "Full screen");
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// Full width HIDES the console beside it — hidden, still mounted.
// The workspace's home sits beside the panel (no session is open), and
// full width HIDES it — hidden, still mounted; same for a console.
const beside = () => screen.getByTestId("workspace-home-column");
expect(beside().className).not.toContain("hidden");
fireEvent.click(size());
expect(card().className).toContain("w-full");
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
expect(beside().className).toContain("hidden");
expect(size()).toHaveAttribute("aria-label", "Restore size");
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// Restore brings the console back beside it.
fireEvent.click(size());
expect(card().className).toContain("w-[25%]");
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
expect(beside().className).not.toContain("hidden");
// Collapsing from full screen reopens at 25/75, not back at full width.
fireEvent.click(size());
@@ -288,11 +291,19 @@ describe("Workspace — the Changes panel's width", () => {
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
expect(api.lanes.git).toHaveBeenCalledWith(1);
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
// A folded section keeps its panel mounted, only hidden.
// An accordion: the first section starts open, the others folded.
const toggle = sections[0]!.querySelector("button")!;
fireEvent.click(toggle);
const shop = sections[1]!.querySelector("button")!;
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(shop).toHaveAttribute("aria-expanded", "false");
// Opening one folds the other; a folded section keeps its panel mounted.
fireEvent.click(shop);
expect(shop).toHaveAttribute("aria-expanded", "true");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
// Clicking the open one folds it too.
fireEvent.click(shop);
expect(shop).toHaveAttribute("aria-expanded", "false");
// A chat's file is the workspace folder's: its section unfolds.
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
@@ -390,13 +401,13 @@ describe("Workspace — the Changes panel's width", () => {
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
// A chat's file inside an inner repo opens in that repo's section.
fireEvent.click(sections[1]!.querySelector("button")!);
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
act(() => {
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
});
await settle();
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
expect(sections[0]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
expect(api.lanes.gitFile).toHaveBeenCalledWith(
{ lane: 2, repo: "apps/web" },
"src/x.ts",
+13 -1
View File
@@ -1203,7 +1203,19 @@ describe("Workspace — home and session view", () => {
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
await renderWorkspace();
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
expect(screen.getByTestId("lane-detail")).toHaveClass("hidden");
// The Changes panel (or its rail) sits beside the home, before any session.
expect(screen.getByTestId("workspace-home-column").parentElement).not.toHaveClass("hidden");
// Its own scroller contains its absolutely placed bits (sr-only labels),
// or they stretch the page past the viewport.
expect(screen.getByTestId("workspace-home-column")).toHaveClass(
"relative",
"overflow-y-auto",
"no-scrollbar"
);
expect(
screen.queryByTestId("lane-changes-card") ?? screen.queryByTestId("lane-changes-expand")
).not.toBeNull();
});
it("opens the session view for ?run=, headed by the tab bar alone", async () => {
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.76 (rebuild client to match)
UI build v2.8.82 (rebuild client to match)
</span>
</p>
</div>
@@ -9204,13 +9204,13 @@ exit 0
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
/>
</svg>
Daily backup
Database backup
</span>
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.
A full copy of the database on a schedule — every day, every week… — into a folder you choose; best on another disk, so a failed drive does not take the backups with it.
</p>
<div
class="mt-2"
@@ -9236,6 +9236,49 @@ exit 0
<div
class="flex flex-wrap items-end gap-3"
>
<div
class="w-40"
>
<div
class="relative"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="How often"
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
title="How often"
type="button"
>
<span
class="flex min-w-0 items-center gap-2"
>
<span
class="truncate"
>
Every day
</span>
</span>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<div
class="w-28"
>
+63 -9
View File
@@ -77,6 +77,8 @@ Authentication is **off by default** (the loopback bind is the trust boundary).
- `x-dashboard-token: <token>` header
- `?token=<token>` query parameter
Local tools find it without being told: the server writes the active token to `<data dir>/runtime/dashboard-token` (0600) at start and removes the file when no token is set, and the `agenthub` CLI, the MCP server and the statusline send `DASHBOARD_TOKEN` from their own environment, else that file. The browser keeps it in `localStorage` (`dashboard_token`) and adds it as `?token=` to what cannot send a header — the `/ws` and `/ws-pty/*` WebSockets, images, downloads. A refused WebSocket upgrade answers `401`/`403` and the server logs why (`[WS] refused …`, at most one line per reason a minute).
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`)
@@ -89,13 +91,13 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `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 **developer** 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 (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
In server mode a **developer** 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 (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them (an admin's), audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). No password again: an admin's session is enough (the role is checked; a `currentPassword` still sent is ignored). `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`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, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or 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).
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Claude `subscriptionType` is the readable plan — "Max 5x", "Team Premium"; for Codex the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email`, `plan` (a login's readable plan — "Max 5x", "Team Premium" — else null; Codex rows carry a ChatGPT login's plan) and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Claude `subscriptionType` is the readable plan — "Max 5x", "Team Premium"; for Codex the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email`, `plan` (a login's readable plan — "Max 5x", "Team Premium" — else null; Codex rows carry a ChatGPT login's plan) and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool}` → `{ok: true}`. No answer carries the token. `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same admin-only check and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{}` → `{ok: true}` (its members go back to the shared pool). Same admin-only check and audit as the other pool changes (no password again).
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
@@ -2098,6 +2100,58 @@ POST /api/locks/:name/release
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
#### Sharing a workspace (server mode)
```http
GET /api/lanes/:id/shares → { shares: [{id, username, name, sharedAt}], canManage }
PUT /api/lanes/:id/shares {"userIds": [2, 5]} → { shares }
```
The users a workspace is shared with (`lane_shares`, names only). `PUT` takes the
WHOLE list — at most 200 enabled users, never the owner (`400 EBADUSER`) — from the
owner or an admin only (`403 ENOTOWNER`), same-origin, each change audited. Local
mode: `GET` answers an empty list, `PUT` `400 ELOCALMODE`. A user it is shared with
sees and works in it like its owner, but `PATCH {title}`, `reset`/`remove`/`purge`,
`DELETE` and `PUT …/shares` answer them `403 ENOTOWNER`. A session they start or
resume (`POST /api/lanes/:id/start`, `POST /api/run`) runs on THEIR OWN account —
a pool setup-token (Claude) or API key (Codex, Cursor) they may use, or an AI
Proxy of theirs — else `409 ENOCOLLABACCOUNT`; resuming a non-Claude session the other side pays
for, or a collaborator's `resumeLast`, is `409 ECOLLABRESUME`; their spend quota (`429 EQUOTA`)
decides, not the owner's. Every pane they open (a Terminal tab too) records them
in `dashboard_runs.started_by`: its pane token acts as them, and an AI pane's
session hours are billed to them (`session_payers`; a Terminal tab's stay the
owner's). A user disabled since stays on the
list (`disabled: true`) and may be kept or removed; only a new one must be enabled. Users taken off
get the WebSocket message `lane_access` `{laneId, userIds}` (sent to them only). See
`docs/SERVER-MODE.md` → Sharing a workspace. A collaborator's pane token
answers only that workspace's reads, the workspace list (filtered to it), its
stage / up / down / hook / feature / mcp-sync writes and `/api/health`
(`403 EPANESCOPE` — never a start or resume).
```http
GET /api/lanes/:id/presence → { panes: { "<run id>": [{id, name}] } }
```
Who has which pane open (server mode; local mode `{panes: {}}`), in memory;
changes come as the WebSocket message `pane_presence` `{laneId, runId, users}`
to the workspace's owner and full collaborators. A run handle
(`GET /api/run`) carries `startedBy: {id, name}` when a collaborator opened
it — they pay for it. The first keystroke of a terminal attach into a shared
workspace's pane by anyone but its owner is audited (`type into <run id>`).
```http
GET /api/lanes/:id/sessions/:sid/shares → { shares, canManage }
PUT /api/lanes/:id/sessions/:sid/shares {"userIds": [3]} → { shares }
```
One session shared on its own (`session_shares`), same rules as the workspace
list (owner or admin, enabled users, audited `share session <id>`, `404
ENOSESSION` for a session of another workspace). The user gets the workspace
in `GET /api/lanes` with `access: "session"`, only that session in its session
lists, the pane holding it, a resume of it (`start {resumeSessionId}` — no
fork, no new session, no shell), and the workspace's git/Files reads and git
writes; any other call on the workspace answers `403 ESESSIONSHARE`.
#### Workspace context: instructions, folders, context files, memory
```http
@@ -3335,7 +3389,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
```http
GET /api/run List all live runs (computed fresh from tmux state)
GET /api/run/tmux { available: bool, version, upgrade } — whether tmux is on PATH, its version, and this platform's upgrade advice when it is older than AgentHub asks for (psmux 3.4.0 on Windows → `winget upgrade marlocarlo.psmux`; tmux 3.7 on macOS → `brew upgrade tmux`, on Linux → build the release), else null; the Workspace shows it as a sticky toast
GET /api/run/tmux { available: bool, version, upgrade, upgradeSeverity } — whether tmux is on PATH, its version, and this platform's upgrade advice when it is older than AgentHub asks for (psmux 3.4.0 on Windows → `winget upgrade marlocarlo.psmux`; tmux 3.7 on macOS → `brew upgrade tmux`, on Linux → build the release), else null; `upgradeSeverity` is `error` for an old psmux (terminals stall — a red toast on every visit) and `warning` for an old tmux (only the pane scrollbar is missing — an amber toast, once per version in a browser)
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
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
@@ -3517,7 +3571,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
```http
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, exportsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled?: boolean, exportsEnabled?: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub (installs: server mode only)
PUT /api/artifacts/settings {installsEnabled?: boolean, exportsEnabled?: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub in local mode, Administration → Server settings in server mode (installs: server mode only)
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
POST /api/artifacts/page {url, title?, description?}
@@ -3602,15 +3656,15 @@ A report (`server/lib/reports.js`) covers the `window_days` (1–366) full local
The scheduler (started with the server's background services, every `DASHBOARD_REPORTS_MS`, default 60000, `0` = off) runs a report when its latest scheduled instant is newer than `last_slot`, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` for a bad field. Definitions and runs survive **Clear Data**, like alert rules and snapshots.
### Daily database backup
### Scheduled database backup
```
GET /api/settings/backup { dir, hour, keep, running, last, files: [{ name, size, mtime }], dir_error, env_owned: { dir, hour, keep } }
PUT /api/settings/backup { dir?, hour?, keep? } → the same state; dir "" turns backups off
GET /api/settings/backup { dir, hour, keep, every, running, last, files: [{ name, size, mtime }], dir_error, env_owned: { dir, hour, keep, every } }
PUT /api/settings/backup { dir?, hour?, keep?, every? } → the same state; dir "" turns backups off
POST /api/settings/backup/run 202 — started (or already running); poll GET while `running`
```
Settings → Data → **Daily backup** (`server/lib/db-backup.js`). Once a day, at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `<name>.partial` and renamed when complete. Beyond `keep` (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by **Back up now**, never every minute. The three values persist to the settings file as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP`; one the environment set is `env_owned` (`409 EENVOWNED`). Writes are same-origin, and admin-only in server mode. `400 INVALID_PATH` for a relative, missing, non-directory or unwritable folder, `400 INVALID_INPUT` for an hour or count out of range, `400 ENOBACKUPDIR` for a run with no folder. The scheduler beat is `DASHBOARD_BACKUP_TICK_MS` (default 60000, `0` = off).
Settings → Data → **Database backup** (`server/lib/db-backup.js`). Every `every` days (1–30, default 1 = daily, 7 = weekly — due once the newest backup in `dir` is that many calendar days old), at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `<name>.partial` and renamed when complete. Beyond `keep` (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by **Back up now**, never every minute. The three values persist to the settings file as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP`; one the environment set is `env_owned` (`409 EENVOWNED`). Writes are same-origin, and admin-only in server mode. `400 INVALID_PATH` for a relative, missing, non-directory or unwritable folder, `400 INVALID_INPUT` for an hour or count out of range, `400 ENOBACKUPDIR` for a run with no folder. The scheduler beat is `DASHBOARD_BACKUP_TICK_MS` (default 60000, `0` = off).
Restoring is manual, on purpose: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db` (its path is on Settings → System), `agenthub start`.
+1 -1
View File
@@ -99,7 +99,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| Command | Description |
| ------- | ----------- |
| `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 `agenthub stop` hint. 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). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` |
| `agenthub start [--port N] [--host A[,B]\|local] [--allowed-hosts H,..\|none] [--token T\|auto\|off]` | 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 `agenthub stop` hint. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. `--host`, `--allowed-hosts` and `--token` are saved in `<data dir>/network.json` (0600) and applied by every later start (a set environment variable wins); a non-loopback `--host` without a token gets one generated. The links to open — every address of a `0.0.0.0` bind, each with `?token=` — are printed. Requires a built client (`npm run build` once). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` |
| `agenthub stop` | **Quit AgentHub** (issue #65): closes every workspace terminal (`agenthub-lane-*` tmux sessions — running agents stop), stops the server — through `systemctl --user stop` / `launchctl bootout` when the autostart unit runs it — and keeps it stopped: Claude Code's `SessionStart` bootstrap starts no server while `<data dir>/runtime/stopped` exists. `agenthub start` (or any server starting, e.g. a boot/login with autostart on) clears it. With the server already down it only sets the flag |
| `agenthub uninstall [--yes]` | **Uninstall AgentHub** (`scripts/uninstall.js`; Settings → System has the same button). Without `--yes` it only lists what is there and exits 1. With it, in order: closes every workspace terminal, removes the systemd/launchd unit, stops every dashboard server, the status-area icon and workspace stacks, takes AgentHub's own entries out of `~/.claude/settings.json` (hooks, statusline; backup `settings.json.agenthub-uninstall.bak`), Codex `hooks.json` and Antigravity `hooks.json`, deletes the AI Proxy `$CODEX_HOME/<name>.config.toml` files, runs `claude plugin uninstall` for `agenthub@*` and every plugin of the `agenthub` marketplace and removes that marketplace (never a shared one), then deletes `~/.local/bin/agenthub`, `~/.config/agenthub/`, `~/.claude/.agenthub.json`, `~/.claude/agenthub-{proxies,routers,git-credentials,accounts,profiles}`, `agenthub-lanes/.state` and `.locks`, the plugin's cached copies and the data directory (database included). **Kept:** the AI tools' own conversations (`~/.claude/projects`, `~/.codex/sessions`, …), workspace worktrees and branches, anything in your repositories, config backups. A data directory that is the home, `CLAUDE_HOME`, a checkout or not recognisably AgentHub's is refused. A failing step is logged and the rest still run; log: `$TMPDIR/agenthub-uninstall-<uid>.log`. Needs no running server. Run inside a dashboard terminal, it closes that terminal last. |
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
+2 -2
View File
@@ -1006,9 +1006,9 @@ function validateSession(session) {
## Backup Strategies
### Built-in daily backup (Settings → Data → Daily backup)
### Built-in scheduled backup (Settings → Data → Database backup)
Set a folder — best on another disk — and the server writes `agenthub-YYYYMMDD-HHMMSS.db` there once a day at a chosen local hour (default 02:00), keeping the newest 7 (`server/lib/db-backup.js`). It is the `VACUUM INTO` below, run on a read-only connection in a worker thread, so the dashboard keeps serving during the copy. Rotation deletes only those exact file names. To restore: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db`, `agenthub start`. Routes and settings: [API.md → Daily database backup](./API.md#daily-database-backup).
Set a folder — best on another disk — and the server writes `agenthub-YYYYMMDD-HHMMSS.db` there every day, every week or every N days (1–30) at a chosen local hour (default 02:00), keeping the newest 7 (`server/lib/db-backup.js`). It is the `VACUUM INTO` below, run on a read-only connection in a worker thread, so the dashboard keeps serving during the copy. Rotation deletes only those exact file names. To restore: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db`, `agenthub start`. Routes and settings: [API.md → Daily database backup](./API.md#daily-database-backup).
### Online Backup (Recommended)
+3 -1
View File
@@ -245,7 +245,9 @@ NODE_ENV=production # Environment mode
# The server binds 127.0.0.1 by default and is NOT network-reachable. It reads
# transcripts, exports all data, and can spawn `claude`, so only widen the bind
# deliberately — and require a token when you do.
DASHBOARD_HOST=127.0.0.1 # set 0.0.0.0 ONLY if you must expose it
DASHBOARD_HOST=127.0.0.1 # set 0.0.0.0 ONLY if you must expose it; a list
# (192.168.1.20,100.64.0.7) binds each, plus 127.0.0.1
# (`agenthub start --host` saves it in <data dir>/network.json)
DASHBOARD_TOKEN= # required on /api/* + WS when set; use with a non-loopback host
DASHBOARD_ALLOWED_HOSTS= # extra Host names (comma-sep) for a LAN bind
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -38,9 +38,12 @@ build, so the registration survives plugin updates.
| 3 | `~/.claude/.agenthub.json` — the live-dashboard discovery file the CLI uses (`server/lib/dashboard-url.js`) |
| 4 | `http://127.0.0.1:4820` |
When the dashboard sets `DASHBOARD_TOKEN`, give the MCP process the same
variable (`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`); it is
sent as `Authorization: Bearer`.
When the dashboard sets `DASHBOARD_TOKEN`, the MCP server sends it as
`Authorization: Bearer`: its own `DASHBOARD_TOKEN` if set, else the token the
running dashboard publishes for local tools in
`<data dir>/runtime/dashboard-token` (0600, readable by the same account only).
A dashboard of another account needs the variable
(`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`).
## Tools
+175 -23
View File
@@ -152,7 +152,8 @@ 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 **developer** sees only rows of their own workspaces, everywhere: lists,
- A **developer** sees only rows of their own workspaces (and of workspaces
shared with them — see [Sharing a workspace](#sharing-a-workspace)), 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
@@ -235,6 +236,117 @@ hides them for developers. The command and skill lists show a developer only the
own workspaces' project items: `?cwd=` outside one of them answers
`403 EFOLDER`, and with no `cwd` they list no project items at all.
### Sharing a workspace
A workspace's owner (or an admin) shares it with other users from its `⋯`
menu → **Share…** (`WorkspaceShareModal`, `GET/PUT /api/lanes/:id/shares`,
table `lane_shares`). There is one level: **collaborate**. A user it is shared
with sees the workspace, its sessions, Changes, history and Files like its
owner, joins its open terminals, types into them, commits and pushes, edits its
instructions and context — every row and live update, through the same
`scope.js` predicates (`canSeeLane`, `sessionWhere`, `laneWhere`,
`broadcastAudience`'s `viewers`). Only the owner or an admin renames, resets,
removes, deletes or shares it (`403 ENOTOWNER`).
**Whoever starts or resumes a session pays for it.** A session a collaborator
starts or resumes runs on THEIR OWN account and never on the owner's login
(`spawnRun` `startedBy`): a pool **setup-token** (Claude) or **API key**
(Codex, Cursor) their pool group, pin or choice allows (`forCollaborator` —
never a pool *login*, which would be written into the owner's home), or an AI
Proxy profile of theirs (resolved as them; the gateway relays it). With none,
the start is refused (`409 ENOCOLLABACCOUNT`); omp and every other tool, which
can only run on the folder account's own login, are refused too. Their own
spend quota decides (`EQUOTA`), their pane's hooks read their own flag
(`quota-u<id>.json` in the owner's handoff dir under S3, `u<id>.json` on the
host), and a quota sweep ends or blocks only the panes THEY pay for
(`runPayer`). Money is per hour (`session_payers`, `server/lib/session-payers.js`):
a session nobody else ran has no row and is billed exactly as before; a session
a collaborator started is theirs from its first hour; a resume changes hands
from the NEXT hour (the hour it happens in stays with the previous payer), in
either direction. Only a Claude session books its spend by the hour, so only
it can change hands: a Codex/Cursor/other session is billed whole at its start
hour, and resuming one the other side pays for is refused (`409
ECOLLABRESUME`), as is a collaborator's `resumeLast` (it names no session).
A Claude session's sub-agent usage is likewise booked at the session's start
hour, so after a hand-over it stays with the first payer — an accepted
rounding. A Terminal tab stays the owner's money: a `claude` typed there runs
on the folder account's own login. The Users page, spend quotas, Today (`/api/pricing/cost?mine=1`)
and a developer's budgets all count by payer; the workspace owner's agent
instructions still go into every pane of the workspace. `MAX_AI_SESSIONS` counts
per Linux account, so a collaborator's sessions count against the owner's
account limit.
**Under S3 a collaborator works as the OWNER's Linux account** — the folder
decides the account. They can read everything in the owner's home (the owner's
other workspaces, SSH keys, AI logins, the pane tokens of the owner's panes)
and their commits carry the owner's git identity; only AgentHub's `audit_log`
records who did what. The Share dialog says so. Share only with people you
would hand your keyboard to. Two consequences follow and are accepted, not
bugs: a collaborator can read the pane token of a pane the OWNER started (and
so act as the owner through the CLI from it), and `ENOCOLLABACCOUNT` is a
dashboard rule, not a barrier — in a terminal they can run `claude` as the
owner's account on the owner's login, and either side can edit the other's
quota flag file in that home (the flag is fail-open anyway; the start check is
server-side). A collaborator's personal Codex proxy writes no key into the
owner's home, only its endpoint. Every pane the collaborator opens records them
as its starter (`dashboard_runs.started_by`), so its own pane token acts as
THEM, and only while the workspace is still shared with them
(`userFromPaneToken`).
A user already on the list who was disabled since stays listed (marked
disabled, granting nothing) and can be taken off; only a NEW addition must be
enabled. Every share and unshare is audited (`share workspace <id>` /
`unshare workspace <id>`). Taking it back sends the users taken off
`lane_access` (their list re-reads) and closes their open terminals on it at
the next attach re-check. Deleting the workspace, or the user, drops the rows;
giving the workspace to a user it was shared with drops theirs.
**A collaborator's pane token acts as them inside that workspace only.** The
owner's account can read it (it sits in the owner's home), so it answers only
reads of `/api/lanes/<that id>/…` (and `/api/workspaces/<id>/…`), the
workspace list (which then holds that workspace only), `/api/health`, and its
`stage`, `up`, `down`, `hook/<name>`, `features/activate` and `mcp/sync`
writes (`paneLane`, `403 EPANESCOPE` for everything else) — never a start,
resume or any other call that could bill them. The CLI's workspace commands
work from their pane; the MCP server and the statusline's spend line do not.
A pane the owner or an admin opens still acts as the owner.
**Who is here, and who pays.** A pane's tab shows the other people who have
it open (`pane_presence`, `GET /api/lanes/:id/presence`, in memory — never
the viewer themself), and its tooltip says "Billed to <name>" for a pane a
collaborator opened (`startedBy` on the run). The first keystroke of each
terminal attach into a SHARED workspace's pane (whole or one session of it)
by anyone but its owner is written to the audit log as `type into <run id>` —
who typed where, never what (mouse, focus and the terminal's own answers to
tmux do not count); the Chat composer types through the same attach, so its
prompts are covered too. A terminal that stops answering pings (a sleeping
laptop) is ended at the next re-check, and its presence with it.
#### Sharing one session
The owner (or an admin) can also share ONE session: right-click it in the
sidebar → **Share…** (`GET/PUT /api/lanes/:id/sessions/:sid/shares`, table
`session_shares`, audited as `share session <id>`). The user sees the
workspace in their list (`access: "session"`) with only that session in it,
attaches to the pane holding it, sends it messages (a resume, on their own
account and billed to them, as above) and uses the workspace's Changes, git
and Files reads and git writes. Everything else of the workspace — another
session, a new session, a fork, a Terminal tab, `/api/run`, its instructions,
context and settings — answers `403 ESESSIONSHARE` (`sessionShareAllows` in
`scope.js`, checked in `scopeGuard`). Their run lists show only runs that held
that session, and a pane switching to another session closes their terminal
at the next attach re-check. Taking it back, deleting the workspace or the
user drops the rows. A resume names a free slot (`slot: "auto"`, never pane
0) and a session of THAT workspace; the session's name and archive state stay
the owner's (`PATCH /api/sessions/<id>` is `ESESSIONSHARE`), and the
workspace's extra folders (`?source=`), its files list for Artifacts and its
stage history stay out of reach. **These limits hold for the dashboard's API
only.** Under S3 the pane runs as the owner's Linux account: inside it the
user can `/resume` any session of the folder, run shell commands and read the
owner's home — a session share hands over as much as a workspace share
there. The Share dialog says so; share only with people you would hand your
keyboard to.
### AI Proxy profiles
Each user may keep **personal** AI Proxy profiles beside the **shared** ones:
@@ -352,6 +464,14 @@ settings file that lacks the variable falls back to `USERS_ROOT` in
Pet uploads are admin-only.
- The native file picker stays local-only (`EREMOTE`) and every page falls back
to its folder browser.
- Every `claude` pane the server starts gets `DISABLE_ERROR_REPORTING=1`: a
crash report's stack trace can carry a user's paths and Linux account name,
and turning it off costs no feature. Telemetry and feature flags stay on —
Claude Code's telemetry carries no code, paths or commands, and turning it
off (`DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`) would
take away flag-gated features such as artifact comments, synced skills and
plugins, the advisor and MCP tasks. Set `DISABLE_ERROR_REPORTING` in the
dashboard's own environment to override. Local mode is unchanged.
## Accounts
@@ -361,9 +481,9 @@ that changes, in a single save, its display name and email, role,
active/disabled, password (typed or generated), the workspaces it owns (give
one, or take one back — it is then left with no owner) and "sign out
everywhere". Every field is checked before any is written, so a bad email or
a workspace the user does not own changes nothing. Every change asks for the admin's OWN
password again — the server refuses without it (`EREAUTH`, and `ETOOMANY`
after 10 wrong tries in 15 minutes) — an admin can never demote or disable
a workspace the user does not own changes nothing. Only an admin can make a
change (the server checks the role; there is no password again — only admins
reach Administration) — an admin can never demote or disable
themselves (`ESELF`), no path (the CLI included) may demote, disable or
delete the last enabled admin (`ELASTADMIN`), and each change is written to
the audit log. A row says **New password due** while the account still has a
@@ -467,9 +587,12 @@ a login is single use, so the server is the one that refreshes it, before it
expires (`claude-pool.refreshLogins`, checked every 60 s; `DASHBOARD_POOL_REFRESH_MS=0` turns it off). It then writes the new pair into every
home it placed the login in, and Claude Code picks up tokens another process
refreshed. A pair a member's Claude refreshed first is copied back only after
Anthropic confirms it is the same account. Removing the account deletes it from
Anthropic confirms it is the same account; while Anthropic does not answer, the
home is kept and checked again on the next beat. A home still holding one of the
account's earlier pairs (its Claude raced the server's refresh) stays the pool's
and gets the newest pair, so its member is not asked to `/login`. Removing the account deletes it from
the homes that still hold AgentHub's copy. Each
change asks for the admin's own password again and is written to the audit log
change is the admin's alone and is written to the audit log
by account name. The token only goes in; the page, like `agenthub accounts list`,
never shows it again. An analyst sees the card read-only; a developer does not
see it. Every user shares the pool: a user created later needs no setup of their own.
@@ -503,8 +626,8 @@ How a new session picks its account (`server/lib/claude-pool.js`):
- **Switching a user to one account.** An admin can take one user off the
ranking: the pencil (**Edit**) on that account's row in the Account pool
card — tick the users who run on it, untick to send one back to automatic (or
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>`; their
own password again, each change audited). A user ticked on one account
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>`; each
change audited). A user ticked on one account
moves off any other. Every session in a
workspace that user OWNS then runs on that account, even when it is full;
other users are not affected, and the account is not reserved for them.
@@ -534,6 +657,18 @@ How a new session picks its account (`server/lib/claude-pool.js`):
> launch file instead of tmux `-e`), but no other account's process or file.
> The AI Proxy gateway does not cover pool tokens.
## Administration → Server settings
Everything that acts on the whole server — not on one user — is an admin's
and lives on **Administration → Server settings** (`/admin?tab=server`), not
in Settings: laptop connections and every enrolled machine, **Tests on this
server**, **Oh My Pi login sharing**, Artifacts Hub installs, AgentHub's git
logins, scheduled reports, start at login / the tray icon / Quit / Uninstall,
model pricing and the Data page (import, cleanup, export). `/settings/data`
and `/settings/pricing` redirect there. An analyst sees Administration's
users only; a developer never sees Administration. Local mode keeps all of
these in Settings.
## Users' own machines (Company account)
A user with their own computer can run **AgentHub local** there and still use
@@ -544,10 +679,21 @@ hour and when the session ends. A session on the user's own login sends
nothing, and the server never reads or runs anything on their machine.
Design and limits: `docs/plans/remote-clients/README.md`.
**It is OFF until an admin turns it on**: Administration → Server settings → Company hub →
**Allow laptop connections** (asked once; audited). While
off, a machine cannot enroll and an enrolled one can neither use the pool nor
send anything (`403 EREMOTEOFF`) — every company session runs on this server,
and nothing of a user's own machine reaches the server or Anthropic. While on,
a laptop session still tells Anthropic the laptop's working path, OS and a
random device id (Claude Code puts them in every request); the server itself
keeps no host name, no IP and no folder path of the machine — only the
project folder's name, with the account and folders above it shown as `~` in
the uploaded conversations — and the pane sends no telemetry of its own.
On the user's machine (company LAN only, plain HTTP):
```bash
agenthub remote login http://10.1.13.7:4820 --user mia --name mia-laptop # hub password, asked hidden
agenthub remote login http://10.1.13.7:4820 --user mia # hub password, asked hidden; --name only if you want one
agenthub remote status # hub, device, what was sent and when
agenthub remote upload # send now instead of waiting for the hour
agenthub remote logout # the token stops working on the server at once
@@ -559,9 +705,10 @@ a resume. The device token lasts 90 days; after it expires or is revoked, log
in again and resume any session that was open (a running pane keeps the token
it started with).
On the server: Settings → AI tools → Company hub lists the user's own
machines (Revoke, their password again) and who opened their sessions; an
admin sees and revokes any user's machines on the **Users** page. Their
On the server: Settings → AI tools → Company hub lists a user's own
machines (Revoke) and who opened their sessions; an
admin sees every machine, with the switch, under Administration → Server
settings → Company hub, and one user's on their row of the **Users** table. Their
sessions are listed device › folder, with what the gateway counted; one used
with the device token outside AgentHub is flagged **unmonitored**, and an
alert rule of type **Company account outside AgentHub** (Settings → Alerts)
@@ -663,7 +810,8 @@ by team (Users page → **Account pool**, where a group is called a pool, server
group.
- A login already live in a member's home stays until their panes end (one
`.credentials.json` / `auth.json` per home), even after a group change.
- Each change needs the admin's own password again and is audited. Removing
- Each change is an admin's and is audited; the board saves every drag by
itself (no Save button). Removing
an account takes it out of every group; deleting a group sends its members
back to the shared pool and removes no account.
@@ -692,15 +840,20 @@ tools**. An account's Edit (pencil) uses the same role / No pool list.
### A user's own pool account
When the pool holds more than one account a user may use, they pick which one
their own new sessions run on: **Settings → AI tools → Pool account**, one
select per tool (Claude, Codex, Cursor), every role, server mode only
(`GET/PUT /api/me/pool-accounts`, `account_choices`, `server/lib/account-pins.js`).
their own new sessions run on in **Settings → AI tools → Profiles**: each pool
account they may use is a row of that tool's tab (Claude, Codex, Cursor) with
its quota and a **Switch to**, beside an **Automatic** row — there is no
separate Pool account section. Every role, server mode only; a developer's
Profiles shows only these pool rows, staff see the host's saved logins too, and
a saved profile with the same email as a pool account is shown once, as the
pool row (`GET/PUT /api/me/pool-accounts`, `account_choices`,
`server/lib/account-pins.js`).
- The list is only what their group allows (or the shared pool); a choice
outside it is refused (`ENOACCOUNT`), and one later taken from the group
falls back to the balancer.
- **Automatic** (the default) leaves it to the balancer.
- An admin's pin wins and locks the select (`ELOCKED`); the choice comes back
- An admin's pin wins and locks the rows (`ELOCKED`); the choice comes back
once the pin is removed.
- A Claude login goes into the user's home at once, like an admin's switch,
so running panes follow it; Codex and Cursor take it at the next session (a
@@ -722,7 +875,7 @@ switcher, as with **Move to pool** in Settings).
With one Linux user per account, a member's omp has its own `~/.omp` and no
login. An admin can let every member's omp run on the HOST account's omp
logins instead: **Settings → AI tools → Oh My Pi login sharing**, or the
logins instead: **Administration → Server settings → Oh My Pi login sharing**, or the
**Oh My Pi** tab of the Users page's Account pool (off by default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
broker and gateway (`server/lib/omp-share.js`):
@@ -776,8 +929,7 @@ An admin limits what each user may spend, in dollars, on the Users page
- A period is `day` (from local midnight of the zone the admin's browser was
in when they saved it) or `5h` (sliding: the current UTC hour and the four
before it — spend booked in hour H counts until H + 5 h).
- Both writes need the admin's own password again and are audited, like
every account change; each one re-applies at once (`sweep`).
- Both writes are an admin's and are audited, like every account change; each one re-applies at once (`sweep`).
Spend is the users page's own figure: the tokens of the sessions the user
OWNS, priced by `calculateCost`. A session is owned through its workspace;
@@ -1111,8 +1263,8 @@ a pane really is. Each case below is refused or bounded, with a test in
installed* badge), its sessions and whether an agent of theirs is working.
The row's **Restart** button ends one member's server — every pane of theirs closes, their
Claude sessions resume from the workspace — and **Restart N idle members**
(above the table) does it for every outdated member with no agent working; both take the
admin's password again and are audited per member. A server that will not
(above the table) does it for every outdated member with no agent working; both are an
admin's and are audited per member. A server that will not
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
- **A member's huge or sparse files.** Transcripts and journals are read line
by line with a 64 MiB cap per line (a 512 MB line used to crash the server);
@@ -1223,7 +1375,7 @@ tests run on each member's own machine:
`npm run test:*`, `php artisan test`, `go test`, `cargo test`, `docker`,
`podman`, … and tells Claude to leave testing to the member's machine.
A person typing a command in a Terminal tab, and Codex, are not covered.
An admin can allow them again with **Settings → AI tools → Tests on this
An admin can allow them again with **Administration → Server settings → Tests on this
server** (`PUT /api/ai-tools/member-policy` `{allowTests}`, kept in
`/opt/agenthub/app/member-policy.json`, no file = blocked); it takes effect
at members' next Bash call, no restart.
+1 -1
View File
@@ -21,7 +21,7 @@ stopped dashboard costs one timeout per 30 s, not one per prompt. When the
dashboard is down the part is simply left out. The script never throws, stops
itself after 2.5 s and always exits 0. The dashboard is found like the CLI finds
it (`AGENTHUB_URL`, then `CLAUDE_DASHBOARD_PORT`/`DASHBOARD_PORT`, then
`~/.claude/.agenthub.json`); `DASHBOARD_TOKEN` is sent when set.
`~/.claude/.agenthub.json`); `DASHBOARD_TOKEN` (else the token the running dashboard publishes in `<data dir>/runtime/dashboard-token`) is sent when there is one.
## Install / uninstall
+35 -1
View File
@@ -171,7 +171,7 @@ fails if anything outside the folder requires it):
| File | Does |
|---|---|
| `devices.js` | `ahdev_<32 bytes base64url>` tokens, only the SHA-256 kept; 90 days (`AGENTHUB_DEVICE_TOKEN_DAYS`); revoke |
| `remote-sessions.js` | registration (UUID, absolute cwd, `claude` only); one session = one device — another user's device gets `409 EFOREIGN`, the SAME user's new device (enrolled again) takes it over (`moveSession` moves its copy) |
| `remote-sessions.js` | registration (UUID, folder name only since 11-09, `claude` only); one session = one device — another user's device gets `409 EFOREIGN`, the SAME user's new device (enrolled again) takes it over (`moveSession` moves its copy) |
| `remote-gateway.js` | `/api/remote/gw/v1/*`: device token → registered session (waits `AGENTHUB_REMOTE_REGISTER_GRACE_MS`, 5 s, for one in flight) → `claude-pool.forRemote` (sticky per session) → upstream `AGENTHUB_REMOTE_UPSTREAM`; usage read from a decompressed COPY of the answer (Anthropic sends it gzip/br) |
| `remote-ingest.js` | upload chunks at the hub's own offset (`409 EOFFSET` + `expected`), copy under `<data>/remote/u<uid>/d<did>/<sid>/`, `importFromDirectory` once per round (chunks carry `more: true`, the round's closing request imports; a round cut short is imported after `AGENTHUB_REMOTE_FLUSH_MS`, 2 min), then `sessions.source = device:<id>`, `owner_id`; `purgeOld` after `AGENTHUB_REMOTE_RETAIN_DAYS` (30) keeps token rows |
| `index.js` | `/api/remote/device/*` (device token: login, me, logout, sessions, end, files, upload), `/api/remote/*` (signed in: devices, revoke with password + audit, sessions, "who opened mine"), hourly purge |
@@ -208,6 +208,40 @@ Known limits:
- `AGENTHUB_REMOTE_GATE=block` is covered by unit tests only.
- A shared AI Proxy as a company account, and Codex, are not built.
## Privacy hardening (2026-11-09)
Measured with a capture server and Claude Code 2.1.319 (the build members
run): every `/v1/messages` call carries `metadata.user_id` =
`{"device_id": <random 64-hex, made at the config dir's first run, fixed>,
"account_uuid": "", "session_id"}`; the headers carry only the CLI/Node
versions, `Linux`/`x64` and the session id — no host name, account or path;
the system prompt carries `Primary working directory: <path>`, `Platform` and
`OS Version`. Outside the base URL Claude Code also connected to
`api.anthropic.com` itself (telemetry with the device id, distro, kernel,
terminal — no host name or path — queued on disk when it fails);
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` stopped all of it.
So a laptop session always tells Anthropic the laptop's working path, OS and
device id (the prompt has to keep the real path or the agent breaks), and the
hub stored the machine's host name (device name), its last IP, each session's
whole path and transcripts full of `/home/<account>/…`. Decided (user,
11-09): the company works on the hub (server mode, S3); laptops are an
exception an admin turns on.
| Change | Where |
|---|---|
| **Admin switch, OFF by default**: while off, device login, every `/api/remote/device/*` route and the gateway answer `403 EREMOTEOFF`; the signed-in views keep working. `GET/PUT /api/remote/policy` (PUT: admin + own password, audited), Settings → AI tools → Company hub | `server/hub/remote-policy.js` (`<data>/remote-hub.json`), `index.js`, `remote-gateway.js`, `client/src/components/remote/RemotePolicy.tsx` |
| No address: `devices.last_ip` is never written (`lastIp` always null) | `devices.js` |
| No host name: a client enrolls under the name the user gives, else none (hub: `device`; the UI shows `device #<id>`) | `remote-client.js` `login` |
| No path: a session registers its folder's last segment; the hub cuts an older client's full path the same way | `remote-client.js`, `remote-sessions.js` `cleanCwd` |
| Transcripts: before upload, the folders above the project and the home become `~` (also as Claude's `-home-<account>-…` folder names), the host name `device`, the account in `ls -l` columns `user` | `server/lib/path-privacy.js`, `remote-client.js` `redactLine` |
| No direct traffic: a Company-account pane gets `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` | `remote-client.js` `claudeEnv` |
| Stored data cleaned ONCE per hub (marker `<data>/remote/.private-v1`): device names → `device`, IPs dropped, folder → label, the upload copy (folder renamed) and the importer snapshot `<data>/transcripts/<sid>…` rewritten, every text column of the session's rows (not `transcript_path`, whose project segment is moved), audit lines naming a device; token rows untouched | `server/hub/remote-privacy.js`, run from `startTimers` |
Still reaching Anthropic from a laptop while the switch is on: the working
path and OS in the system prompt, the random device id, the message
content. Only the hub-only setup hides those.
## Open questions
- Should an admin be able to mark a workspace "company account required"?
+279
View File
@@ -0,0 +1,279 @@
# Workspace & session sharing — plan
Status: **phases 1–4 built 2026-11-06** (workspace sharing; collaborators run on their own account, billed per payer by hour — `session_payers`; one session can be shared — `session_shares`; pane presence + typing audit; R1 decided as option **(a)** — accept and warn hard).
An independent review of phase 1 found and got fixed: a collaborator's Terminal
tab acted as the owner through its pane token (now `dashboard_runs.started_by`),
`POST /api/run {shell:true}` bypassed `ECOLLABSTART`, a disabled collaborator
locked the share list, and the home's prompt box let a collaborator type into a
Start that always failed. Accepted under (a): the owner's own panes' tokens are
readable by a collaborator, and `ECOLLABSTART` is advisory under S3.
Server mode only. In local mode there are no users, so there is nothing to share
and every piece below is a no-op (`isServerMode()`).
## What the user asked for
Members of the same AgentHub install work together on one workspace or one
session. Decisions taken with the repo owner (2026-11-06):
| Question | Decision |
| --- | --- |
| What may a person it is shared with do? | **Always collaborate** — the same as the owner inside that workspace/session: type into panes, send prompts, start sessions, commit, push. No view-only level. |
| Unit of sharing | **Both** — a whole workspace (every session, present and future) or a single session. |
| Who pays for a session a collaborator starts? | **The person who opens it** — its tokens, cost and spend quota are theirs, not the workspace owner's. |
| Whose AI subscription does a collaborator's run use? | **Their own, or none**: only a pool account of kind `token` that the collaborator may use (passed by env). With a `login`-kind pool or no pool the run is **refused** (`ENOCOLLABACCOUNT`) — a collaborator never spends the owner's subscription. |
| Spend quota | **Only the payer's** (the person who started or resumed the run). The owner's quota is not involved. |
| A collaborator resumes someone else's session | **The resumer pays from then on** — billing is per RUN, not per session (below). |
| Session-only share and git | **Allowed** — commit/push in that session's tree, like everything else. |
| Sharing with an analyst | **Allowed** — a shared workspace counts as their own for writes. |
## The one fact everything else follows from (S3)
Under S3 a pane runs as the Linux account of its **folder**
(`accountForPath`, `server/lib/run-as.js`), and only the folder's account may
own a workspace there (`assertOwnerFits`, `EOWNERFOLDER`). Sharing does **not**
change that: a collaborator's work in Alice's workspace runs as `ah-alice`.
What that means, and the UI must say it once, plainly, in the Share dialog:
- **Git identity, SSH key, HTTPS store, commit signing are the owner's.** A
collaborator's commit is authored by the owner (`git config --global` of the
owner's account). AgentHub's `audit_log` records who clicked; git does not.
- **Files written are the owner's.** Everything a collaborator's session
creates belongs to `ah-alice`.
- **The owner's `~/.claude` applies** — their skills, memory, MCP servers,
settings — to the collaborator's sessions too.
- **The owner's account can read the collaborator's pane token** (it is in a
launch file in the owner's home, like every pane secret). So the collaborator's
token must be scoped to the shared workspace only (below), never their whole
account.
There is no way around these without moving the folder to a shared account —
out of scope. Sharing is therefore something you do with people you would
also hand your keyboard to; the dialog says so.
## Data model (migration-safe, `server/db.js`)
```sql
CREATE TABLE IF NOT EXISTS lane_shares (
lane_id INTEGER NOT NULL REFERENCES lanes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (lane_id, user_id)
);
CREATE TABLE IF NOT EXISTS session_shares (
session_id TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (session_id, user_id)
);
-- who started a dashboard run (a collaborator's run is billed to them)
ALTER TABLE dashboard_runs ADD COLUMN started_by INTEGER; -- guarded like every ALTER in db.js
```
No `role`/`level` column: there is one level (collaborate). Adding view-only
later is one column.
## Who may do what
| Action | Owner | Workspace collaborator | Session collaborator | Admin |
| --- | --- | --- | --- | --- |
| See the workspace, its sessions, Changes, history | ✓ | ✓ | that session + the workspace's Changes panel | ✓ |
| Attach to / type into a live pane | ✓ | ✓ every pane | that session's pane | ✓ |
| Send a message to an ended session (resume) | ✓ | ✓ | that session | ✓ |
| Start a NEW session | ✓ | ✓ (billed to them) | ✗ | ✓ |
| Git writes (commit, push, shelve, rewrite…) | ✓ | ✓ | ✓ (the session's tree) | ✓ |
| Workspace instructions, context files, extra folders | ✓ | ✓ | ✗ | ✓ |
| Rename / Remove / Reset the workspace | ✓ | ✗ | ✗ | ✓ |
| Share / unshare | ✓ | ✗ | ✗ | ✓ |
| `agenthub stage`, lane runtime `up/down/hook` | ✓ | ✓ | ✗ | ✓ |
Owner-only actions get an `assertLaneOwner(req, laneId)` (owner or admin);
everything else becomes "owner **or** collaborator".
## Server changes
1. **`server/lib/scope.js`** — the one place that decides visibility:
- `laneWhere` / `canSeeLane`: `owner_id = ? OR id IN (SELECT lane_id FROM lane_shares WHERE user_id = ?)`.
- `ownedSessionWhere`: add `OR col IN (SELECT session_id FROM session_shares WHERE user_id = ?)` and sessions of shared lanes (through the same lane predicate).
- `PATH_RULES` / `namedIds` need no new rule (they call `canSeeLane`/`canSeeSession`); a new route under a new prefix (`/api/shares`) gets its own rule.
- Keep `memberId` as is: an analyst still reads everything and writes only what they own **or collaborate on**.
2. **Session ownership & billing ("whoever opens it pays")**
- `spawnRun` takes `startedBy = req.user.id`; writes `dashboard_runs.started_by`.
- `bindRunSession` (`server/routes/hooks.js`) stamps `sessions.owner_id = started_by` when it differs from the lane owner. `sessions.owner_id` already exists (remote clients) and `scope.js`, `session-account.js`, `users.js` honour it.
- `server/routes/users.js` ownership CTE: `sessions.owner_id` must WIN over the lane owner (today it is a UNION — a session could count twice). Same in `user-quota.js` spend (`hourlyTokenRows` + `calculateCost`, never a second formula).
- Spend quota: `assertSpawnAllowed(startedBy)` instead of the lane owner, and `hook-handler.js`'s `UserPromptSubmit` check reads the flag of the RUN's payer (the quota file is per Linux account today → per account + payer user id, passed to the pane as `AGENTHUB_PAYER_ID`).
- **Billing is per run, not per session.** A session can be paid by several
people over time: whoever starts or resumes a run pays for everything that
run does, including what anyone types into its live pane (keystrokes cannot
be attributed; the run's starter can). New table
`session_payers(session_id, user_id, from_at)` — one row per run start whose
payer differs from the previous one. `hourlyTokenRows` books a session's
tokens per UTC hour already; each hour goes to the payer whose `from_at` is
the latest ≤ that hour (a run started mid-hour takes the hour from the next
one on — documented rounding, never a second cost formula). With no row the
session's owner pays, as today. `sessions.owner_id` keeps meaning "who
opened it" (visibility); `session_payers` only moves money.
3. **AI login of a collaborator's run — their own or refused.**
- The collaborator's pool pick (`pickAccount(startedBy)`, their group, pin
and own choice) must be of kind `token`: it goes in as
`CLAUDE_CODE_OAUTH_TOKEN` through tmux `-e` / the launch file, overriding
the owner's home login for that pane only.
- A `login`-kind pick, or no pool account for them: `spawnRun` refuses with
`409 ENOCOLLABACCOUNT` before anything starts, for a new session AND for a
resume. The home's Start button says why. The owner's login is never used
for a collaborator's run.
- Codex / other tools: same rule with their own env-only kinds (Codex API key
`AGENTHUB_POOL_KEY`, Cursor `CURSOR_API_KEY`); a ChatGPT login (written into
the home as `auth.json`) is refused like a Claude `login`.
4. **Pane token.** A collaborator's session gets a pane token of the
collaborator, **scoped to that lane**: `dashboard_runs.pane_token_hash` plus a
`scope_lane_id`, and `userFromPaneToken` refuses anything outside that lane.
`peerIsPaneAccount` keeps checking the folder's account (`ah-alice`) — the
token is accepted from that uid because the pane runs as it.
5. **Live co-working.** tmux already allows several attaches with a shared
size (`pty-attach.js`). Add:
- presence: who is attached to which pane (in-memory, broadcast to the lane's audience);
- an audit line per attach-with-input and per prompt sent from the chat composer (`audit_log`, actor = the real user). Keystrokes are not logged.
6. **WebSocket** — `broadcastAudience` (`server/websocket.js`): every lane- or
session-scoped message also reaches the lane's/session's collaborators. A new
type `shares_changed` (owner + affected user).
7. **Routes** (`server/routes/shares.js`, same-origin, audited, server mode only):
- `GET /api/lanes/:id/shares`, `PUT /api/lanes/:id/shares {userIds}` (owner/admin)
- `GET /api/sessions/:id/shares`, `PUT /api/sessions/:id/shares {userIds}` (owner/admin)
- `GET /api/me/shared` — what is shared with me
- only enabled users; never the owner themself; a disabled user's shares stay but grant nothing.
8. **Revoke.** Removing a share closes that user's live attaches on the lane
(their websocket attach ends), revokes their pane tokens' lane scope, and
leaves their running sessions running (they run as the owner; the owner can
stop them). Deleting a lane cascades its shares.
## Client changes
- **Share dialog** from the workspace's `⋯` menu and the session's right-click
menu: user picker (enabled users, not me), the current list with Remove, and
the S3 warning above. `confirmDialog` on Share, `toast` on the result.
- **Sidebar**: a "Shared with me" group (workspaces shared with me, and loose
shared sessions under their workspace name, marked). A share icon on rows I
shared.
- **Presence**: small avatars on a pane's tab of who else is attached.
- **Recents / session list**: the opener's avatar on a session a collaborator
started; the cost column is the opener's.
- Owner-only controls hidden for collaborators (the server refuses anyway —
`ENOTOWNER`, 403).
## Docs to update when built
`docs/SERVER-MODE.md` (new § Sharing + scope table), `docs/LANES.md`,
`docs/API.md`, `CLAUDE.md` (the scope.js rule and the S3 rule gain "or a
collaborator"), `README.md` feature list.
## Phases
1. **S1 — Workspace sharing, visibility + live panes.** Tables, scope.js,
broadcastAudience, Share dialog, sidebar group, attach/type, git writes.
Collaborators can join live panes but cannot start sessions yet.
2. **S2 — Collaborator runs on their own account, billed per run.**
`started_by`, `sessions.owner_id` stamping, `session_payers`, users.js /
quota precedence (and the double count fixed), pane token scope, pool
`token` env, `ENOCOLLABACCOUNT`.
3. **S3 — Session-only sharing.** `session_shares`, its scope rules and UI.
4. **S4 — Presence + audit of prompts.**
Each phase ships on its own; tests per phase: `scope.test.js` (every predicate
with and without a share), `server-mode-review-fixes.test.js` style 404s for a
non-collaborator, users/quota attribution, websocket audience, and the client
dialog/sidebar tests.
## Self-review against the code (2026-11-06)
Checked every claim above against `server/lib/scope.js`, `auth.js`
(`userFromPaneToken`), `hook-auth.js` (`peerIsPaneAccount`), `pty-run.js`
(`spawnRun`), `user-quota.js`, `lane-sources.js` (`launchExtras`),
`websocket.js` (pty attach) and `routes/users.js`.
**R1 — CRITICAL: sharing a workspace hands out a shell as the owner's whole
account, not just that workspace.** A collaborator who types into a pane (or
whose prompt makes Claude run Bash) runs as `ah-alice`, whose home holds ALL of
Alice's workspaces, `~/.ssh` keys, `~/.config/agenthub/` HTTPS store, Claude and
Codex logins (`.credentials.json`, `auth.json`) and the env of her live panes.
So Bob can read Alice's other projects, copy her SSH key and her Claude
subscription token, and — via `$AGENTHUB_PANE_TOKEN` in any of her panes — call
the CLI/MCP **as Alice** (`userFromPaneToken` returns the lane owner, and is
accepted from Alice's uid, which Bob's typing runs as). This also defeats
decision 1 ("never the owner's subscription"): Bob can simply use her login.
The section "The one fact everything else follows from" understated this.
Options:
- **(a) Accept it, warn hard.** Share = "give this person your account". Simple,
honest, no isolation.
- **(b) Team workspaces in their own Linux account.** A shared workspace lives
under a dedicated account (`ah-team-<id>`, provisioned by `agenthub-provision`
like a member, its own home, git identity, logins = the pool's `token` kinds
only). Every member of the team works there; nobody's personal home is
exposed. Real isolation; more work (provisioning, git identity per team,
`assertOwnerFits` learns team folders, moving an existing workspace = clone).
- **(c) Chat-only collaborators.** No terminal for collaborators — but Claude's
own Bash tool still runs as Alice, so this isolates nothing. Not proposed.
**R2 — Typing into the owner's live pane spends the owner's subscription.**
Unavoidable while the pane runs on her login; contradicts the spirit of
decision 1. Under (b) it disappears (team panes use pool `token` accounts).
**R3 — Pane tokens.** `userFromPaneToken` maps a token to the LANE OWNER. Needs
`dashboard_runs.started_by` → the token's user. And every pane token of a shared
lane (the owner's too, see R1) must be limited to that lane, or one leaked token
is a whole account.
**R4 — Session-only sharing does not fit the current checks.** `canSeeRun` and
every `/api/lanes/:id/...` route decide by LANE (`canSeeLane`). Granting a
session collaborator `canSeeLane` would show them the whole workspace. Needs a
run/session-level predicate and an allowlist of lane routes usable with
`?session=<shared id>` only. Recommend: drop session-only sharing from the first
release, or limit it to that session's pane + chat (no Changes panel).
**R5 — Per-account limits are shared.** `MAX_AI_SESSIONS` (default 4) and
`member-guard.js` count per Linux account, so collaborators' sessions eat the
owner's 4 (no issue under (b): the team account has its own limit).
**R6 — Spend-quota flag is one file per account** (`quota.json` in the member's
handoff dir, `flagEnv`). Payer-based quota needs one file per payer
(`quota-u<id>.json`) and `AGENTHUB_QUOTA_FILE` pointing at the payer's.
**R7 — `launchExtras` puts the lane OWNER's personal instructions** first in
every pane; a collaborator's session should get the starter's.
**R8 — AI Proxy.** `resolveProxy(tool, proxy, laneOwner)` resolves by owner: a
collaborator could spend the owner's proxy key. Resolve by the starter — and a
collaborator's OWN proxy profile is a valid "own account" besides a `token` pool
account (decision 1 should allow it).
**R9 — Other `owner_id = ?` call sites** to extend or keep owner-only, checked
one by one: `scope.js` (`laneWhere`, `canSeeLane`, `visibleLaneIds`,
`insideOwnWorkspace`), `auth.js` (demote/disable ends a user's host panes —
must also end runs they `started_by` elsewhere), `pool-usage.js`,
`member-tmux.js`, `me.js` counts, `users.js`.
**R10 — Simplification found:** the pty attach socket already re-checks its user
periodically ("still owning the pane's workspace", `websocket.js`), so revoking
a share closes live attaches by just changing the predicate — no new kill path.
## Answered questions (2026-11-06)
1. A collaborator's run uses **their own `token`-kind pool account, or is
refused** (`ENOCOLLABACCOUNT`) — never the owner's subscription.
2. Spend quota: **only the payer's**.
3. Resume by someone else: **the resumer pays from then on** (per-run billing,
`session_payers`).
4. Session-only share: **commit/push allowed**.
5. Analysts: **sharing gives write** in that workspace.
## Still to settle while building
- The per-hour rounding of `session_payers` (a run started at 10:40 by Bob in a
session Alice paid at 10:05): the hour goes to the earlier payer and Bob pays
from 11:00, or token rows get a finer key. Phase 2 measures how often it
matters before adding a finer key.
- Typing into a live pane started by someone else costs the run's starter. The
pane's header shows "billed to <name>" so nobody is surprised.
+7 -5
View File
@@ -5,8 +5,8 @@
* `tools/list`, `tools/call`). Every tool is one GET against the running
* dashboard's HTTP API, so the numbers are the dashboard's own; nothing here
* opens the database or writes anything. The base URL is `AGENTHUB_URL`, else
* the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN`, when
* set, is sent as a bearer token. Run it as `agenthub mcp` or
* the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN` —
* else the token the running dashboard published — is sent as a bearer token. Run it as `agenthub mcp` or
* `node mcp/server.js`; see docs/MCP.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -161,9 +161,11 @@ const TOOLS = {
/** GET a dashboard path; resolves to an MCP tool result, never rejects. */
async function callApi(pathname) {
const url = `${baseUrl()}${pathname}`;
const headers = process.env.DASHBOARD_TOKEN
? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` }
: {};
// DASHBOARD_TOKEN, else the token the running server published.
const token = require(
path.join(__dirname, "..", "server", "lib", "dashboard-url.js")
).dashboardToken();
const headers = token ? { Authorization: `Bearer ${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;
+1234 -208
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.76",
"version": "2.8.82",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.76",
"version": "2.8.82",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.76",
"version": "2.8.82",
"bin": {
"agenthub": "bin/agenthub.js"
},
+4 -3
View File
@@ -739,8 +739,8 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data (`purge_days` whole ≥ 1, else `400 INVALID_PURGE_DAYS`; `dry_run: true` counts only) |
| `GET` | `/api/settings/privacy` | `{redact_secrets, ignore_cwd[], env_owned: {redact_secrets, ignore_cwd}}` (`server/lib/privacy.js`) |
| `PUT` | `/api/settings/privacy` | `{redact_secrets?: boolean, ignore_cwd?: string[]}` → persisted to the settings file as `AGENTHUB_REDACT_SECRETS` / `AGENTHUB_IGNORE_CWD` (`sameOriginGuard`). 400 `EBADPATH` (relative, a root, a control char or the list separator), 409 `EENVOWNED`, 500 `ESAVE` |
| `GET` | `/api/settings/backup` | `{dir, hour, keep, running, last, files[], dir_error, env_owned: {dir, hour, keep}}` — the daily database backup (`server/lib/db-backup.js`); `files` are AgentHub's own `agenthub-YYYYMMDD-HHMMSS.db` in `dir`, newest first; `last` is this process's last run |
| `PUT` | `/api/settings/backup` | `{dir?: string, hour?: 0–23, keep?: 1–365}` → persisted as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP` (`sameOriginGuard`); an empty `dir` turns backups off. 400 `INVALID_PATH` (relative, missing, not a directory, not writable) / `INVALID_INPUT`, 409 `EENVOWNED`, 500 `ESAVE` |
| `GET` | `/api/settings/backup` | `{dir, hour, keep, every, running, last, files[], dir_error, env_owned: {dir, hour, keep, every}}` — the scheduled database backup (`server/lib/db-backup.js`); `files` are AgentHub's own `agenthub-YYYYMMDD-HHMMSS.db` in `dir`, newest first; `last` is this process's last run |
| `PUT` | `/api/settings/backup` | `{dir?: string, hour?: 0–23, keep?: 1–365, every?: 1–30}` → persisted as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP` / `AGENTHUB_BACKUP_EVERY` (`sameOriginGuard`); an empty `dir` turns backups off. 400 `INVALID_PATH` (relative, missing, not a directory, not writable) / `INVALID_INPUT`, 409 `EENVOWNED`, 500 `ESAVE` |
| `POST` | `/api/settings/backup/run` | Start a backup now and answer `202` at once (poll `GET` while `running`); 400 `ENOBACKUPDIR` (`sameOriginGuard`) |
| `GET` | `/api/settings/push-token` | `{enabled, env_owned}` — never the token |
| `POST` | `/api/settings/push-token` | Make a new push-ingest token (the old one stops working) and return it this ONCE as `token`; only its SHA-256 is stored (`AGENTHUB_PUSH_TOKEN_SHA256`). `sameOriginGuard`; 409 `EENVOWNED` |
@@ -1633,10 +1633,11 @@ DASHBOARD_SYNC_LIVE_HOOK_MS=300000 # project sync leaves a transcript alone whil
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
DASHBOARD_LIVENESS_IDLE_SECONDS=60 # Idle gate before the liveness reap may complete a process-less session
DASHBOARD_REPORTS_MS=60000 # Scheduled-reports beat (ms); 0 disables the scheduler
DASHBOARD_BACKUP_TICK_MS=60000 # Daily-backup scheduler beat (ms); 0 disables it. The folder/hour/count are set in Settings → Data → Backup:
DASHBOARD_BACKUP_TICK_MS=60000 # Backup scheduler beat (ms); 0 disables it. The folder/hour/count are set in Settings → Data → Backup:
AGENTHUB_BACKUP_DIR= # folder for agenthub-YYYYMMDD-HHMMSS.db (empty = off); only those files are ever rotated out
AGENTHUB_BACKUP_HOUR=2 # server local hour (0–23); a day missed while the server was down runs at the next start
AGENTHUB_BACKUP_KEEP=7 # backups kept (1–365)
AGENTHUB_BACKUP_EVERY=1 # days between backups (1 = daily, 7 = weekly; 1–30)
# Remote Data Sources (SSH pull; see the Remote Data Sources section)
DASHBOARD_REMOTE_SYNC_MS=15000 # Remote-source sync poll interval (ms); 0 disables the poller
@@ -182,6 +182,31 @@ describe("a pool login", () => {
assert.equal(diskCreds().refreshToken, other.refreshToken);
});
it("keeps a home whose Claude kept an old pair of the pool's, and rewrites it", async () => {
pool.addLoginAccount("team", { oauth: oauth("a", 10 * 60e3), account: ACCOUNT });
pool.forSpawn(acct).place();
await pool.refreshLogins(); // the server spends a → r1
// The member's Claude raced the refresh and wrote the spent pair back.
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("a", 10 * 60e3) }));
const spawn = pool.forSpawn(acct);
assert.equal(spawn?.name, "team", "an old pair of the pool's is still the pool's home");
await pool.refreshLogins();
assert.ok("mia" in pool.__read("team").placed, "never dropped");
assert.equal(diskCreds().refreshToken, oauth("r1").refreshToken, "the newest pair written");
});
it("does not drop a home while Anthropic cannot say whose newer pair it holds", async () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
pool.forSpawn(acct).place();
const theirs = oauth("cli", 9 * H);
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: theirs }));
await pool.refreshLogins(); // fetchOwner answers null: offline
assert.ok("mia" in pool.__read("team").placed, "kept for a later beat");
owners[theirs.accessToken] = { uuid: "uuid-team" };
await pool.refreshLogins();
assert.equal(pool.__read("team").oauth.refreshToken, theirs.refreshToken, "copied back");
});
it("is taken out of the homes it is still ours in when removed", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
pool.forSpawn(acct).place();
+180
View File
@@ -0,0 +1,180 @@
/**
* @file collab-runs.test.js
* @description A session started by a user a workspace is shared with
* (sharing phase 2, pty-run.js spawnRun `startedBy`): it runs on a pool
* setup-token of THEIR OWN through tmux `-e` (never the owner's pick, never a
* login), is refused with `ENOCOLLABACCOUNT` when they have none, records
* `started_by`, makes them the payer of the new session from its first hour
* (session-payers.js), points its hooks at their own quota flag and is
* refused while they are over their quota. The owner's own start is unchanged.
* @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(), "collab-runs-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;
const realFetchOwner = ccUsage.fetchOwner;
let argv = null;
let alice;
let bob;
let lane;
const envOf = (name) => {
const hit = (argv || []).find((x) => String(x).startsWith(`${name}=`));
return hit ? String(hit).slice(name.length + 1) : undefined;
};
before(() => {
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
ccUsage.fetchOwner = async () => 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 "";
});
const user = (name) =>
Number(
db
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', 'developer')")
.run(name).lastInsertRowid
);
alice = user("alice");
bob = user("bob");
lane = Number(
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(TMP, alice)
.lastInsertRowid
);
db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(lane, bob);
process.env.AGENTHUB_MODE = "server";
});
after(() => {
delete process.env.AGENTHUB_MODE;
ccUsage.fetchUsage = realFetchUsage;
ccUsage.fetchOwner = realFetchOwner;
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
db.prepare("DELETE FROM session_payers").run();
db.prepare("DELETE FROM account_pins").run();
db.prepare("DELETE FROM user_quotas").run();
pool.__quotaCache.clear();
argv = null;
});
describe("a collaborator's own session", () => {
it("is refused without an account of their own, never run on the owner's", () => {
assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), {
code: "ENOCOLLABACCOUNT",
});
assert.equal(argv, null, "no pane was started");
// The owner's own start is as before.
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude" });
const row = db.prepare("SELECT started_by FROM dashboard_runs").get();
assert.equal(row.started_by, null);
});
it("runs on their own pool token, records them and makes them its payer", () => {
pool.addAccount("theirs", TOKEN("b"));
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob, slot: 1 });
assert.equal(envOf("CLAUDE_CODE_OAUTH_TOKEN"), TOKEN("b"));
assert.ok(argv.indexOf(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("b")}`) < argv.indexOf("--"));
// Their own quota flag, never the owner's.
assert.match(envOf("AGENTHUB_QUOTA_FILE"), new RegExp(`u${bob}\\.json$`));
const run = db
.prepare("SELECT session_id, started_by, account FROM dashboard_runs WHERE id = ?")
.get(`agenthub-lane-${lane}-1`);
assert.equal(run.started_by, bob);
assert.equal(run.account, "theirs");
assert.deepEqual(
db.prepare("SELECT session_id, from_hour, user_id FROM session_payers").all(),
[{ session_id: run.session_id, from_hour: "0000", user_id: bob }]
);
});
it("starts nothing while they are over their own quota — and the owner still starts", () => {
pool.addAccount("theirs", TOKEN("b"));
const MODEL = "claude-sonnet-4-5-20250929";
const hour = new Date().toISOString().slice(0, 13);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sBob', 'b', 'active', ?, ?, ?)"
).run(TMP, lane, new Date().toISOString());
db.prepare(
"INSERT INTO token_usage (session_id, model, output_tokens) VALUES ('sBob', ?, 10000000)"
).run(MODEL);
db.prepare(
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES ('sBob', ?, ?, 10000000)"
).run(hour, MODEL);
db.prepare("INSERT INTO session_payers VALUES ('sBob', '0000', ?)").run(bob);
const q = require("../lib/user-quota");
for (const id of [alice, bob]) {
q.setUserQuota(id, { mode: "custom", limit_usd: 1, period: "5h", tz_offset: 0 });
}
assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), {
code: "EQUOTA",
});
// Those hours are bob's money, not alice's.
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", slot: 2 });
assert.ok(argv, "the owner's pane started");
});
it("never resumes a non-Claude session across payers, nor the latest one for a collaborator", () => {
pool.addAccount("theirs", TOKEN("b"));
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('sCodexAAA', 'a', 'completed', ?, ?, 'codex')"
).run(TMP, lane);
assert.throws(
() =>
pty.spawnRun({
laneId: lane,
cwd: TMP,
tool: "codex",
resumeSessionId: "sCodexAAA",
startedBy: bob,
}),
{ code: "ECOLLABRESUME" }
);
// Nor the owner a session the collaborator pays for.
db.prepare("INSERT INTO session_payers VALUES ('sCodexBBB', '0000', ?)").run(bob);
assert.throws(
() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "codex", resumeSessionId: "sCodexBBB" }),
{ code: "ECOLLABRESUME" }
);
assert.throws(
() =>
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", resumeLast: true, startedBy: bob }),
{ code: "ECOLLABRESUME" }
);
});
it("keeps a Terminal tab's session the owner's money", () => {
const payers = require("../lib/session-payers");
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, started_by)
VALUES ('agenthub-lane-shell', 'sShell', 'interactive', ?, 'running', ?, 'shell', ?)`
).run(TMP, lane, bob);
payers.noteRun("agenthub-lane-shell");
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM session_payers").get().n, 0);
});
});
@@ -0,0 +1,54 @@
/**
* @file dashboard-token-publish.test.js
* @description A token-gated dashboard publishes its token (0600) for local
* tools, and `dashboard-url.dashboardToken()` — what the `agenthub` CLI, the
* MCP server and the statusline send — takes DASHBOARD_TOKEN first, else that
* file, else nothing. Without it `agenthub remote login/status` answered 401
* from any shell that had not exported the token.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "dash-token-"));
process.env.DASHBOARD_DATA_DIR = TMP;
const security = require("../lib/security");
const { dashboardToken, tokenHeaders } = require("../lib/dashboard-url");
describe("the published dashboard token", () => {
let saved;
before(() => {
saved = process.env.DASHBOARD_TOKEN;
});
afterEach(() => {
if (saved === undefined) delete process.env.DASHBOARD_TOKEN;
else process.env.DASHBOARD_TOKEN = saved;
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
it("is written 0600 when the server has one, and read back without the env", () => {
process.env.DASHBOARD_TOKEN = "server-tok";
security.publishDashboardToken();
const file = security.tokenFilePath();
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
delete process.env.DASHBOARD_TOKEN; // a shell that never exported it
assert.equal(dashboardToken(), "server-tok");
assert.deepEqual(tokenHeaders(), { "x-dashboard-token": "server-tok" });
});
it("prefers the caller's own DASHBOARD_TOKEN", () => {
process.env.DASHBOARD_TOKEN = "mine";
assert.equal(dashboardToken(), "mine");
});
it("is removed when the server runs without a token", () => {
delete process.env.DASHBOARD_TOKEN;
security.publishDashboardToken();
assert.equal(fs.existsSync(security.tokenFilePath()), false);
assert.equal(dashboardToken(), null);
assert.deepEqual(tokenHeaders(), {});
});
});
+23 -3
View File
@@ -3,7 +3,8 @@
* complete, openable copy of the live database under a server-made name, no
* `.partial` is left behind, rotation deletes only AgentHub's own
* `agenthub-YYYYMMDD-HHMMSS.db` files (never a look-alike, a symlink or anything
* else in the folder), the once-a-day schedule decision, and the folder check.
* else in the folder), the schedule decision (daily, or every N days), and the
* folder check.
* The settings file and the folders are temp paths.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,7 +19,12 @@ const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-backup-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
for (const k of ["AGENTHUB_BACKUP_DIR", "AGENTHUB_BACKUP_HOUR", "AGENTHUB_BACKUP_KEEP"]) {
for (const k of [
"AGENTHUB_BACKUP_DIR",
"AGENTHUB_BACKUP_HOUR",
"AGENTHUB_BACKUP_KEEP",
"AGENTHUB_BACKUP_EVERY",
]) {
delete process.env[k];
}
@@ -140,6 +146,16 @@ describe("dueNow", () => {
assert.equal(backup.dueNow(at(9), cfg, [], "20261008"), false));
it("does nothing with no folder", () =>
assert.equal(backup.dueNow(at(9), { ...cfg, dir: "" }, [], null), false));
it("weekly: waits until the newest backup is 7 calendar days old", () => {
const weekly = { ...cfg, every: 7 };
const sixAgo = [{ name: "agenthub-20261002-020000.db" }];
const sevenAgo = [{ name: "agenthub-20261001-020000.db" }];
assert.equal(backup.dueNow(at(9), weekly, sixAgo, null), false);
assert.equal(backup.dueNow(at(9), weekly, [...sevenAgo, ...sixAgo], null), false);
assert.equal(backup.dueNow(at(9), weekly, sevenAgo, null), true);
assert.equal(backup.dueNow(at(9), weekly, [], null), true);
assert.equal(backup.dueNow(at(1), weekly, sevenAgo, null), false);
});
});
describe("Settings routes", () => {
@@ -150,6 +166,7 @@ describe("Settings routes", () => {
assert.equal(body.dir, "");
assert.equal(body.hour, 2);
assert.equal(body.keep, 7);
assert.equal(body.every, 1);
assert.deepEqual(body.files, []);
});
@@ -160,6 +177,8 @@ describe("Settings routes", () => {
{ hour: 1.5 },
{ keep: 0 },
{ keep: "7" },
{ every: 0 },
{ every: 31 },
]) {
const { status } = await call("PUT", "/api/settings/backup", bad);
assert.equal(status, 400, JSON.stringify(bad));
@@ -168,13 +187,14 @@ describe("Settings routes", () => {
it("PUT persists to the settings file; POST /run starts a backup", async () => {
const dir = folder("route");
const put = await call("PUT", "/api/settings/backup", { dir, hour: 3, keep: 5 });
const put = await call("PUT", "/api/settings/backup", { dir, hour: 3, keep: 5, every: 7 });
assert.equal(put.status, 200);
assert.equal(put.body.dir, dir);
const saved = fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8");
assert.match(saved, new RegExp(`^AGENTHUB_BACKUP_DIR=${dir}$`, "m"));
assert.match(saved, /^AGENTHUB_BACKUP_HOUR=3$/m);
assert.match(saved, /^AGENTHUB_BACKUP_KEEP=5$/m);
assert.match(saved, /^AGENTHUB_BACKUP_EVERY=7$/m);
const run = await call("POST", "/api/settings/backup/run");
assert.equal(run.status, 202);
+527
View File
@@ -0,0 +1,527 @@
/**
* @file lane-shares.test.js
* @description Sharing a workspace with other users (server mode,
* docs/plans/workspace-sharing/ phase 1): only the owner or an admin manages
* the list (`GET/PUT /api/lanes/:id/shares`, audited, enabled users only, never
* the owner); the user it is shared with sees the workspace and its sessions
* but may not rename, reset, remove or delete it (`ENOTOWNER`); an AI session
* they start needs an account of their own (`ENOCOLLABACCOUNT`) and each hour
* of a session is billed to whoever ran it then (users page, quotas, Today); a
* Terminal tab they open acts as THEM through its pane token, never as the
* owner; a collaborator disabled since does not block the list; a user it is
* not shared with still gets 404; taking it back ends their access at once.
* @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(), `lane-shares-${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 = {};
const users = {};
let laneA;
async function call(method, p, { as, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
Origin: BASE,
...(as ? { cookie: cookies[as] } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
users.alice = await auth.createUser("alice", "alice-pass-1", "developer");
users.bob = await auth.createUser("bob", "bob-pass-1", "developer");
users.carol = await auth.createUser("carol", "carol-pass-1", "developer");
users.boss = await auth.createUser("boss", "admin-pass-1", "admin");
laneA = Number(
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice', 'claude', ?)")
.run(users.alice.id).lastInsertRowid
);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sA', 'alice one', 'active', '/w/alice', ?)"
).run(laneA);
process.env.AGENTHUB_MODE = "server";
for (const [name, pass] of [
["alice", "alice-pass-1"],
["bob", "bob-pass-1"],
["carol", "carol-pass-1"],
["boss", "admin-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 });
}
});
const laneIds = async (as) => (await call("GET", "/api/lanes", { as })).body.lanes.map((l) => l.id);
describe("sharing a workspace", () => {
it("is managed only by its owner (or an admin), for enabled users other than the owner", async () => {
assert.equal((await call("GET", `/api/lanes/${laneA}/shares`, { as: "bob" })).status, 404);
const put = (as, userIds) =>
call("PUT", `/api/lanes/${laneA}/shares`, { as, body: { userIds } });
assert.equal((await put("bob", [users.bob.id])).status, 404);
assert.equal((await put("alice", [users.alice.id])).body.error.code, "EBADUSER");
assert.equal((await put("alice", [9999])).body.error.code, "EBADUSER");
assert.equal((await put("alice", "bob")).body.error.code, "EBADUSER");
const r = await put("alice", [users.bob.id]);
assert.equal(r.status, 200);
assert.deepEqual(
r.body.shares.map((u) => u.username),
["bob"]
);
const audit = db
.prepare("SELECT user_id, owner_id, path FROM audit_log WHERE path LIKE 'share workspace%'")
.all();
assert.deepEqual(audit, [
{ user_id: users.alice.id, owner_id: users.bob.id, path: `share workspace ${laneA}` },
]);
});
it("shows the workspace and its sessions to the user it is shared with — and nobody else", async () => {
assert.ok((await laneIds("bob")).includes(laneA));
assert.equal((await call("GET", "/api/sessions/sA", { as: "bob" })).status, 200);
const shares = await call("GET", `/api/lanes/${laneA}/shares`, { as: "bob" });
assert.equal(shares.status, 200);
assert.equal(shares.body.canManage, false);
assert.ok(!(await laneIds("carol")).includes(laneA));
assert.equal((await call("GET", "/api/sessions/sA", { as: "carol" })).status, 404);
});
it("keeps rename, remove, delete and sharing the owner's", async () => {
const code = (r) => r.body?.error?.code;
assert.equal(
code(await call("PATCH", `/api/lanes/${laneA}`, { as: "bob", body: { title: "mine" } })),
"ENOTOWNER"
);
assert.equal(
code(
await call("POST", `/api/lanes/${laneA}/remove`, { as: "bob", body: { confirm: true } })
),
"ENOTOWNER"
);
assert.equal(code(await call("DELETE", `/api/lanes/${laneA}`, { as: "bob" })), "ENOTOWNER");
assert.equal(
code(
await call("PUT", `/api/lanes/${laneA}/shares`, {
as: "bob",
body: { userIds: [users.bob.id, users.carol.id] },
})
),
"ENOTOWNER"
);
// Notes are not owner-only: a collaborator writes them like the owner.
const notes = await call("PATCH", `/api/lanes/${laneA}`, { as: "bob", body: { notes: "hi" } });
assert.equal(notes.status, 200);
// The owner and an admin still rename it.
assert.equal(
(await call("PATCH", `/api/lanes/${laneA}`, { as: "alice", body: { title: "A" } })).status,
200
);
assert.equal(
(await call("PATCH", `/api/lanes/${laneA}`, { as: "boss", body: { title: "B" } })).status,
200
);
});
it("starts a collaborator's AI session only on an account of their own", async () => {
// No pool account and no AI Proxy of theirs: refused, never the owner's login.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lane-shares-cwd-"));
db.prepare("UPDATE lanes SET cwd = ? WHERE id = ?").run(dir, laneA);
let r;
let run;
try {
r = await call("POST", `/api/lanes/${laneA}/start`, {
as: "bob",
body: { slot: "auto", prompt: "hi" },
});
run = await call("POST", "/api/run", {
as: "bob",
body: { laneId: laneA, initialPrompt: "hi" },
});
} finally {
db.prepare("UPDATE lanes SET cwd = '/w/alice' WHERE id = ?").run(laneA);
fs.rmSync(dir, { recursive: true, force: true });
}
assert.equal(r.status, 409, JSON.stringify(r.body));
assert.equal(r.body.error.code, "ENOCOLLABACCOUNT");
assert.equal(run.status, 409, JSON.stringify(run.body));
assert.equal(run.body.error.code, "ENOCOLLABACCOUNT");
});
it("bills each hour of a session to whoever ran it then", async () => {
const payers = require("../lib/session-payers");
const MODEL = "claude-sonnet-4-5-20250929";
const hour = (h) => new Date(Date.now() - h * 3600e3).toISOString().slice(0, 13);
const spend = (sid, h, tokens) => {
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens) VALUES (?, ?, ?)
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
).run(sid, MODEL, tokens);
db.prepare(
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES (?, ?, ?, ?)"
).run(sid, hour(h), MODEL, tokens);
};
const started = new Date(Date.now() - 3 * 3600e3).toISOString();
db.prepare(
`INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at)
VALUES ('sPaid', 'paid', 'active', '/w/alice', ?, ?, ?)`
).run(laneA, started, new Date().toISOString());
spend("sPaid", 2, 1_000_000); // alice's hours
// Bob resumes it now: his from the next hour on.
const runIns = (id, by) =>
db
.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, started_by, started_at)
VALUES (?, 'sPaid', 'interactive', '/w/alice', 'completed', ?, 'claude', ?, ?)`
)
.run(id, laneA, by, new Date().toISOString());
runIns("agenthub-lane-x-bob", users.bob.id);
const now = Date.now();
payers.noteRun("agenthub-lane-x-bob", now);
assert.deepEqual(
db
.prepare(
"SELECT from_hour, user_id FROM session_payers WHERE session_id = 'sPaid' ORDER BY from_hour"
)
.all(),
[
{ from_hour: "0000", user_id: users.alice.id },
{ from_hour: new Date(now + 3600e3).toISOString().slice(0, 13), user_id: users.bob.id },
]
);
// Noting the same payer again changes nothing.
payers.noteRun("agenthub-lane-x-bob", now);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM session_payers WHERE session_id = 'sPaid'").get().n,
2
);
// Book bob's hour as the next one (a test cannot wait an hour).
db.prepare(
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES ('sPaid', ?, ?, 2000000)"
).run(new Date(now + 3600e3).toISOString().slice(0, 13), MODEL);
db.prepare(
"UPDATE token_usage SET output_tokens = output_tokens + 2000000 WHERE session_id = 'sPaid'"
).run();
const resolve = payers.payerResolver();
assert.equal(resolve({ session_id: "sPaid", date: hour(2) }), users.alice.id);
assert.equal(
resolve({ session_id: "sPaid", date: new Date(now + 3600e3).toISOString().slice(0, 13) }),
users.bob.id
);
// The users page: each sees only the hours they paid (2:1 tokens).
const details = async (u) =>
(await call("GET", `/api/users/${u.id}/details`, { as: "boss" })).body.usage.last7d;
const a = await details(users.alice);
const b = await details(users.bob);
assert.ok(a.cost > 0 && b.cost > 0, JSON.stringify({ a, b }));
assert.ok(Math.abs(b.cost / a.cost - 2) < 0.01, JSON.stringify({ a, b }));
const list = (await call("GET", "/api/users", { as: "boss" })).body.users;
const last7 = (name) => list.find((u) => u.username === name).last7d.cost;
assert.ok(Math.abs(last7("bob") / last7("alice") - 2) < 0.01, JSON.stringify(list));
// Spend quotas count the same hours.
const q = require("../lib/user-quota");
q.setUserQuota(users.bob.id, { mode: "custom", limit_usd: 1000, period: "day", tz_offset: 0 });
q.setUserQuota(users.alice.id, {
mode: "custom",
limit_usd: 1000,
period: "day",
tz_offset: 0,
});
const st = q.statusAll({ now: now + 3600e3 + 60e3, userIds: [users.alice.id, users.bob.id] });
assert.ok(Math.abs(st.get(users.bob.id).spend - b.cost) < 0.01, JSON.stringify([...st]));
q.setUserQuota(users.bob.id, { mode: "default" });
q.setUserQuota(users.alice.id, { mode: "default" });
// Today (`mine=1`) is each one's own money, never the other's hours.
const mine = async (as) =>
(await call("GET", "/api/pricing/cost?mine=1", { as })).body.total_cost;
assert.ok(Math.abs((await mine("bob")) - b.cost) < 0.01);
assert.ok(Math.abs((await mine("alice")) - a.cost) < 0.01);
db.prepare("DELETE FROM dashboard_runs WHERE id = 'agenthub-lane-x-bob'").run();
});
it("makes a pane token act as the user who opened that pane, never the owner", () => {
const tmux = require("../lib/tmux");
const real = tmux.hasSession;
tmux.hasSession = () => true;
try {
const tokenFor = (runId, startedBy) => {
const cred = auth.newPaneToken();
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id, tool, pane_token_hash, started_by)
VALUES (?, 'interactive', '/w/alice', 'running', ?, 'shell', ?, ?)`
).run(runId, laneA, cred.hash, startedBy);
return cred.token;
};
const asUser = (token) =>
auth.userFromPaneToken({
headers: { "x-agenthub-pane-token": token },
socket: { remoteAddress: "127.0.0.1" },
});
assert.equal(asUser(tokenFor(`agenthub-lane-${laneA}-1`, null))?.username, "alice");
const bobs = tokenFor(`agenthub-lane-${laneA}-2`, users.bob.id);
assert.equal(asUser(bobs)?.username, "bob");
// …inside this workspace only: the owner's account can read that token.
assert.equal(asUser(bobs)?.paneLane, laneA);
const guard = (p, method = "GET") => {
let out = null;
auth.authGuard(
{
path: p,
method,
headers: { "x-agenthub-pane-token": bobs },
socket: { remoteAddress: "127.0.0.1" },
},
{
status: (code) => ({ json: (body) => (out = { code, body }) }),
},
() => (out = "next")
);
return out;
};
assert.equal(guard(`/lanes/${laneA}`), "next");
assert.equal(guard(`/workspaces/${laneA}/git`), "next");
assert.equal(guard("/sessions").body.error.code, "EPANESCOPE");
assert.equal(guard(`/lanes/${laneA + 1000}`).body.error.code, "EPANESCOPE");
assert.equal(guard("/me").body.error.code, "EPANESCOPE");
// The CLI finds its workspace in the list (which answers that one only)…
assert.equal(guard("/workspaces"), "next");
// …and never starts or bills a session with it.
assert.equal(guard(`/lanes/${laneA}/start`, "POST").body.error.code, "EPANESCOPE");
assert.equal(guard(`/lanes/${laneA}/reset`, "POST").body.error.code, "EPANESCOPE");
assert.equal(guard("/run", "POST").body.error.code, "EPANESCOPE");
// No longer shared with him: his pane's token is nobody.
db.prepare("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?").run(
laneA,
users.bob.id
);
assert.equal(asUser(bobs), null);
db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(
laneA,
users.bob.id
);
} finally {
tmux.hasSession = real;
db.prepare("DELETE FROM dashboard_runs WHERE lane_id = ?").run(laneA);
}
});
it("is not blocked by a collaborator disabled since", async () => {
const put = (userIds) =>
call("PUT", `/api/lanes/${laneA}/shares`, { as: "alice", body: { userIds } });
assert.equal((await put([users.bob.id, users.carol.id])).status, 200);
db.prepare("UPDATE users SET disabled = 1 WHERE id = ?").run(users.carol.id);
try {
const listed = await call("GET", `/api/lanes/${laneA}/shares`, { as: "alice" });
assert.equal(listed.body.shares.find((u) => u.id === users.carol.id)?.disabled, true);
// Kept on the list while another change is saved, then taken off.
assert.equal((await put([users.bob.id, users.carol.id])).status, 200);
const r = await put([users.bob.id]);
assert.equal(r.status, 200);
assert.deepEqual(
r.body.shares.map((u) => u.username),
["bob"]
);
// But never newly added while disabled.
assert.equal((await put([users.bob.id, users.carol.id])).body.error.code, "EBADUSER");
} finally {
db.prepare("UPDATE users SET disabled = 0 WHERE id = ?").run(users.carol.id);
}
});
it("ends their access as soon as it is taken back, and says so in the audit log", async () => {
const r = await call("PUT", `/api/lanes/${laneA}/shares`, {
as: "alice",
body: { userIds: [] },
});
assert.equal(r.status, 200);
assert.deepEqual(r.body.shares, []);
assert.ok(!(await laneIds("bob")).includes(laneA));
assert.equal((await call("GET", "/api/sessions/sA", { as: "bob" })).status, 404);
assert.equal(
db
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path = ? AND owner_id = ?")
.get(`unshare workspace ${laneA}`, users.bob.id).n,
1
);
});
it("goes with the workspace when it is deleted", async () => {
await call("PUT", `/api/lanes/${laneA}/shares`, {
as: "alice",
body: { userIds: [users.carol.id] },
});
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM lane_shares WHERE lane_id = ?").get(laneA).n,
1
);
assert.equal((await call("DELETE", `/api/lanes/${laneA}`, { as: "alice" })).status, 200);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM lane_shares WHERE lane_id = ?").get(laneA).n,
0
);
});
});
describe("sharing one session", () => {
let laneS;
before(() => {
laneS = Number(
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice2', 'claude', ?)")
.run(users.alice.id).lastInsertRowid
);
for (const id of ["s1-shared", "s2-private"]) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'completed', '/w/alice2', ?)"
).run(id, id, laneS);
}
});
const put = (as, userIds, sid = "s1-shared") =>
call("PUT", `/api/lanes/${laneS}/sessions/${sid}/shares`, { as, body: { userIds } });
it("is managed by the workspace's owner only, for a session of that workspace", async () => {
assert.equal((await put("carol", [users.carol.id])).status, 404);
assert.equal((await put("alice", [users.carol.id], "sA")).status, 404, "not this workspace's");
const r = await put("alice", [users.carol.id]);
assert.equal(r.status, 200);
assert.deepEqual(
r.body.shares.map((u) => u.username),
["carol"]
);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path = 'share session s1-shared'").get()
.n,
1
);
});
it("shows that session and its workspace — no other session of it", async () => {
const lanes = (await call("GET", "/api/lanes", { as: "carol" })).body.lanes;
const mine = lanes.find((l) => l.id === laneS);
assert.equal(mine?.access, "session");
const list = await call("GET", `/api/sessions?lane=${laneS}`, { as: "carol" });
assert.deepEqual(
list.body.sessions.map((s) => s.id),
["s1-shared"]
);
assert.equal((await call("GET", "/api/sessions/s1-shared", { as: "carol" })).status, 200);
assert.equal((await call("GET", "/api/sessions/s2-private", { as: "carol" })).status, 404);
});
it("allows that session's pane, Changes and a resume of it — nothing else of the workspace", async () => {
const scope = require("../lib/scope");
const req = { user: { id: users.carol.id, role: "developer" }, method: "POST" };
const run = (id, sid) =>
db
.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool)
VALUES (?, ?, 'interactive', '/w/alice2', 'running', ?, 'claude')`
)
.run(id, sid, laneS);
run(`agenthub-lane-${laneS}-1`, "s1-shared");
run(`agenthub-lane-${laneS}-2`, "s2-private");
assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}-1`), true);
assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}-2`), false);
assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}`), false, "no run there");
const refused = async (method, p, body) => {
const r = await call(method, p, { as: "carol", body });
assert.equal(r.status, 403, `${method} ${p} → ${r.status}`);
assert.equal(r.body.error.code, "ESESSIONSHARE");
};
await refused("PATCH", `/api/lanes/${laneS}`, { title: "x" });
await refused("GET", `/api/lanes/${laneS}/sources`);
await refused("PUT", `/api/lanes/${laneS}/instructions`, { instructions: "x" });
await refused("POST", `/api/lanes/${laneS}/start`, { slot: "auto", prompt: "new" });
await refused("POST", `/api/lanes/${laneS}/start`, { shell: true });
await refused("POST", `/api/lanes/${laneS}/start`, {
resumeSessionId: "s1-shared",
forkSession: true,
});
await refused("POST", "/api/run", { laneId: laneS, initialPrompt: "hi" });
await refused("POST", `/api/lanes/${laneS}/start`, { resumeSessionId: "s1-shared", slot: 0 });
await refused("POST", `/api/lanes/${laneS}/start`, { resumeSessionId: "s1-shared" });
await refused("GET", `/api/lanes/${laneS}/git/tree?source=1`);
await refused("GET", `/api/artifacts/candidates?laneId=${laneS}`);
await refused("PATCH", "/api/sessions/s1-shared", { name: "mine now" });
// A shared session resumed only in its own workspace.
const laneT = Number(
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice3', 'claude', ?)")
.run(users.alice.id).lastInsertRowid
);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sT-other', 't', 'completed', '/w/alice3', ?)"
).run(laneT);
db.prepare("INSERT INTO session_shares (session_id, user_id) VALUES ('sT-other', ?)").run(
users.carol.id
);
await refused("POST", `/api/lanes/${laneT}/start`, {
resumeSessionId: "s1-shared",
slot: "auto",
});
db.prepare("DELETE FROM session_shares WHERE session_id = 'sT-other'").run();
// Another session of it is not even there.
const other = await call("POST", `/api/lanes/${laneS}/start`, {
as: "carol",
body: { resumeSessionId: "s2-private" },
});
assert.equal(other.status, 404);
// A resume of the shared one gets past the scope (and then needs their own account).
const resume = await call("POST", `/api/lanes/${laneS}/start`, {
as: "carol",
body: { resumeSessionId: "s1-shared", prompt: "hi", slot: "auto" },
});
assert.ok(![403, 404].includes(resume.status), JSON.stringify(resume.body));
// Reads of the workspace's git and its own row pass.
assert.equal((await call("GET", `/api/lanes/${laneS}`, { as: "carol" })).status, 200);
const git = await call("GET", `/api/lanes/${laneS}/git`, { as: "carol" });
assert.notEqual(git.status, 403);
db.prepare("DELETE FROM dashboard_runs WHERE lane_id = ?").run(laneS);
});
it("ends their access when it is taken back", async () => {
assert.equal((await put("alice", [])).status, 200);
assert.ok(!(await laneIds("carol")).includes(laneS));
assert.equal((await call("GET", "/api/sessions/s1-shared", { as: "carol" })).status, 404);
});
});
+2
View File
@@ -211,6 +211,8 @@ describe("lane-sources instructions and launch", () => {
// One wheel notch reaches claude as one report; it scrolls this many lines.
if (!process.env.CLAUDE_CODE_SCROLL_SPEED)
assert.ok(newSession.includes("CLAUDE_CODE_SCROLL_SPEED=5"));
// Local mode keeps Claude's error reports (server mode turns them off).
assert.ok(!newSession.some((a) => String(a).startsWith("DISABLE_ERROR_REPORTING")));
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
assert.match(text, /^- always answer in Vietnamese/);
+133
View File
@@ -0,0 +1,133 @@
/**
* @file listen-hosts.test.js
* @description DASHBOARD_HOST may name several addresses, and a dashboard
* bound only to a non-loopback address still answers on 127.0.0.1 — the hook
* handler, the CLI, the MCP server and the statusline all call there (WSL in
* mirrored networking binds a LAN address because Windows cannot reach the
* VM's loopback, and every hook was lost). 127.0.0.2 stands in for the LAN
* address: Linux routes all of 127/8 to lo, yet it is not "this machine's
* name" to the guards.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "listen-hosts-"));
process.env.DASHBOARD_DATA_DIR = TMP;
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.DASHBOARD_POOL_REFRESH_MS = "0";
const security = require("../lib/security");
function withHost(value, fn) {
const saved = process.env.DASHBOARD_HOST;
if (value === undefined) delete process.env.DASHBOARD_HOST;
else process.env.DASHBOARD_HOST = value;
try {
return fn();
} finally {
if (saved === undefined) delete process.env.DASHBOARD_HOST;
else process.env.DASHBOARD_HOST = saved;
}
}
describe("which addresses the dashboard listens on", () => {
it("is loopback alone by default, with nothing extra", () =>
withHost(undefined, () => {
assert.deepEqual(security.resolveHosts(), ["127.0.0.1"]);
assert.deepEqual(security.extraListenHosts(), []);
assert.equal(security.isExposedBind(), false);
}));
it("adds 127.0.0.1 beside a LAN-only bind, and keeps every listed address", () =>
withHost("192.168.1.20, 100.64.0.7", () => {
assert.equal(security.resolveHost(), "192.168.1.20");
assert.deepEqual(security.extraListenHosts(), ["100.64.0.7", "127.0.0.1"]);
assert.equal(security.isExposedBind(), true);
}));
it("adds nothing for a wildcard bind, which answers on loopback already — but calls it exposed", () =>
withHost("0.0.0.0", () => {
assert.deepEqual(security.extraListenHosts(), []);
assert.equal(security.isExposedBind(), true);
assert.ok(security.accessUrls(4821).includes("http://localhost:4821"));
}));
it("lists each bound address as a URL to open", () =>
withHost("192.168.1.20", () => {
assert.deepEqual(security.accessUrls(4821), [
"http://localhost:4821",
"http://192.168.1.20:4821",
]);
}));
});
describe("a dashboard bound to another address", () => {
let server;
after(async () => {
delete process.env.DASHBOARD_HOST;
if (server) await new Promise((r) => server.close(r));
fs.rmSync(TMP, { recursive: true, force: true });
});
it("still answers on 127.0.0.1, WebSocket included", async () => {
process.env.DASHBOARD_HOST = "127.0.0.2";
const { createApp, startServer } = require("../index");
server = await startServer(createApp(), 0);
const { address, port } = server.address();
assert.equal(address, "127.0.0.2");
await new Promise((r) => setTimeout(r, 50)); // the extra listener binds async
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
assert.equal(res.status, 200);
// The upgrade travels the forwarded connection to the same WS server.
const reply = await new Promise((resolve) => {
const sock = require("node:net").connect(port, "127.0.0.1", () =>
sock.write(
`GET /ws HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\n` +
`Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n` +
`Sec-WebSocket-Version: 13\r\n\r\n`
)
);
sock.once("data", (d) => {
resolve(d.toString());
sock.destroy();
});
sock.on("error", (e) => resolve(String(e)));
});
assert.match(reply, /^HTTP\/1\.1 101/);
});
});
describe("saved network settings (agenthub start --host/--token)", () => {
const net = require("../lib/network-config");
const keys = ["DASHBOARD_HOST", "DASHBOARD_ALLOWED_HOSTS", "DASHBOARD_TOKEN"];
it("are written 0600 and applied only where the environment says nothing", () => {
const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
try {
net.writeNetworkConfig({ host: "0.0.0.0", allowedHosts: "box.lan", token: "t1" });
assert.equal(fs.statSync(net.configPath()).mode & 0o777, 0o600);
const env = { DASHBOARD_TOKEN: "from-env" };
net.applyNetworkConfig(env);
assert.deepEqual(env, {
DASHBOARD_TOKEN: "from-env",
DASHBOARD_HOST: "0.0.0.0",
DASHBOARD_ALLOWED_HOSTS: "box.lan",
});
// null removes a key: `--host local`, `--token off`.
net.writeNetworkConfig({ host: null, token: null });
assert.deepEqual(net.readNetworkConfig(), { allowedHosts: "box.lan" });
} finally {
fs.rmSync(net.configPath(), { force: true });
for (const k of keys) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
}
});
});

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