feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+7
View File
@@ -713,6 +713,9 @@ 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` |
| `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` |
| `DELETE` | `/api/settings/push-token` | Revoke it: push ingest off (`sameOriginGuard`) |
@@ -1599,6 +1602,10 @@ DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms);
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:
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)
# 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
+198
View File
@@ -0,0 +1,198 @@
/**
* @file Tests server/lib/db-backup.js and its Settings routes — a backup is a
* 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.
* The settings file and the folders are temp paths.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
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"]) {
delete process.env[k];
}
const { createApp, startServer } = require("../index");
const { db, Database } = require("../db");
const backup = require("../lib/db-backup");
const folder = (name) => {
const dir = path.join(ROOT, name);
fs.mkdirSync(dir, { recursive: true });
return dir;
};
let server;
let BASE;
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
async function call(method, urlPath, body) {
const res = await fetch(BASE + urlPath, {
method,
headers: body ? { "content-type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json() };
}
describe("validateBackupDir", () => {
it("accepts an existing writable folder and resolves it", () => {
const dir = folder("ok");
assert.equal(backup.validateBackupDir(`${dir}/./`), dir);
});
for (const [label, input] of [
["a relative path", "backups"],
["a missing folder", path.join(ROOT, "nope")],
["a control character", `${ROOT}/a\nb`],
]) {
it(`refuses ${label}`, () => {
assert.throws(() => backup.validateBackupDir(input), { code: "INVALID_PATH" });
});
}
it("refuses a file", () => {
const file = path.join(ROOT, "a-file");
fs.writeFileSync(file, "x");
assert.throws(() => backup.validateBackupDir(file), { code: "INVALID_PATH" });
});
});
describe("runBackup", () => {
it("writes a complete copy under a server-made name and leaves no .partial", async () => {
const dir = folder("run");
process.env.AGENTHUB_BACKUP_DIR = dir;
db.prepare(
"INSERT INTO sessions (id, name, status, started_at) VALUES ('backup-marker', 'm', 'completed', '2026-01-01T00:00:00Z')"
).run();
const out = await backup.runBackup();
assert.match(out.file, /^agenthub-\d{8}-\d{6}\.db$/);
assert.deepEqual(fs.readdirSync(dir), [out.file]);
const copy = new Database(path.join(dir, out.file), { readonly: true });
try {
assert.equal(copy.pragma("quick_check", { simple: true }), "ok");
assert.equal(
copy.prepare("SELECT name FROM sessions WHERE id = 'backup-marker'").get().name,
"m"
);
} finally {
copy.close();
}
assert.equal(backup.lastResult().ok, true);
});
it("refuses with no folder set", async () => {
delete process.env.AGENTHUB_BACKUP_DIR;
await assert.rejects(backup.runBackup(), { code: "ENOBACKUPDIR" });
});
});
describe("rotate", () => {
it("keeps the newest N of its own files and touches nothing else", () => {
const dir = folder("rotate");
const own = [
"agenthub-20261001-020000.db",
"agenthub-20261002-020000.db",
"agenthub-20261003-020000.db",
"agenthub-20261004-020000.db",
];
const others = ["notes.txt", "agenthub-latest.db", "agenthub-20261001-020000.db.bak", "my.db"];
for (const f of [...own, ...others]) fs.writeFileSync(path.join(dir, f), "x");
const outside = path.join(ROOT, "outside.db");
fs.writeFileSync(outside, "keep me");
fs.symlinkSync(outside, path.join(dir, "agenthub-20250101-000000.db"));
assert.deepEqual(backup.rotate(dir, 2).sort(), own.slice(0, 2));
assert.deepEqual(
fs.readdirSync(dir).sort(),
[...own.slice(2), ...others, "agenthub-20250101-000000.db"].sort()
);
assert.equal(fs.readFileSync(outside, "utf8"), "keep me");
});
});
describe("dueNow", () => {
const cfg = { dir: "/backups", hour: 2, keep: 7 };
const at = (h) => new Date(2026, 9, 8, h, 30);
const today = [{ name: "agenthub-20261008-020000.db" }];
const yesterday = [{ name: "agenthub-20261007-020000.db" }];
it("waits for the hour", () => assert.equal(backup.dueNow(at(1), cfg, [], null), false));
it("runs once the hour passed with no backup today", () =>
assert.equal(backup.dueNow(at(9), cfg, yesterday, null), true));
it("skips a day that already has one", () =>
assert.equal(backup.dueNow(at(9), cfg, today, null), false));
it("tries once a day, not every minute after a failure", () =>
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));
});
describe("Settings routes", () => {
it("GET answers the defaults with no folder", async () => {
delete process.env.AGENTHUB_BACKUP_DIR;
const { status, body } = await call("GET", "/api/settings/backup");
assert.equal(status, 200);
assert.equal(body.dir, "");
assert.equal(body.hour, 2);
assert.equal(body.keep, 7);
assert.deepEqual(body.files, []);
});
it("PUT validates every field", async () => {
for (const bad of [
{ dir: "relative" },
{ hour: 24 },
{ hour: 1.5 },
{ keep: 0 },
{ keep: "7" },
]) {
const { status } = await call("PUT", "/api/settings/backup", bad);
assert.equal(status, 400, JSON.stringify(bad));
}
});
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 });
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);
const run = await call("POST", "/api/settings/backup/run");
assert.equal(run.status, 202);
for (let i = 0; i < 100 && (await call("GET", "/api/settings/backup")).body.running; i++) {
await new Promise((r) => setTimeout(r, 50));
}
const { body } = await call("GET", "/api/settings/backup");
assert.equal(body.last.ok, true);
assert.equal(body.files.length, 1);
assert.match(body.files[0].name, /^agenthub-\d{8}-\d{6}\.db$/);
});
it("PUT with an empty folder turns backups off", async () => {
const { status, body } = await call("PUT", "/api/settings/backup", { dir: "" });
assert.equal(status, 200);
assert.equal(body.dir, "");
const run = await call("POST", "/api/settings/backup/run");
assert.equal(run.status, 400);
assert.equal(run.body.error.code, "ENOBACKUPDIR");
});
});
+39
View File
@@ -42,6 +42,7 @@ describe("pty-attach", () => {
beforeEach(() => {
fakePty = makeFakePty();
ptyAttach.__setSpawnImpl(() => fakePty);
ptyAttach.__resetGroups();
tmux.__setExecImpl(() => "");
});
afterEach(() => {
@@ -164,6 +165,44 @@ describe("pty-attach", () => {
assert.equal(closed, true);
});
it("holds a second viewer at the first one's size until it takes the lead", () => {
// Two browsers of different sizes on one pane: tmux's `window-size latest`
// resized the window under whichever was not typing. One drives; the other
// renders the driver's grid.
const ptys = [];
ptyAttach.__setSpawnImpl((_f, _a, opts) => {
const p = makeFakePty();
p.opts = opts;
ptys.push(p);
return p;
});
const resize = (ws, cols, rows, drive) =>
ws.__emitter.emit(
"message",
Buffer.from(JSON.stringify({ type: "resize", cols, rows, drive })),
{
binary: false,
}
);
const a = makeFakeWs();
const b = makeFakeWs();
ptyAttach.attach(a, "agenthub-lane-1", { cols: 200, rows: 50 });
ptyAttach.attach(b, "agenthub-lane-1", { cols: 100, rows: 30 });
assert.deepEqual([ptys[1].opts.cols, ptys[1].opts.rows], [200, 50]);
assert.deepEqual(JSON.parse(b.__sent[0].data), { type: "size", cols: 200, rows: 50 });
resize(b, 100, 30); // its own fit, not a takeover: nothing moves
assert.equal(ptys[1].__writes.length, 0);
resize(b, 100, 30, true); // a keystroke in b
assert.deepEqual(ptys[1].__writes.at(-1), { resize: [100, 30] });
assert.deepEqual(ptys[0].__writes.at(-1), { resize: [100, 30] });
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "size", cols: 100, rows: 30 });
b.__emitter.emit("close"); // the driver leaves: the rest size themselves
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
});
it("kills the PTY attach process when the WS connection closes", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
+40
View File
@@ -524,6 +524,46 @@ describe("pty-run", () => {
assert.match(pty.paneArgv(null, "linux")[2], /exec "\$SHELL" -l$/);
});
it("hands a long prompt to the pane through a file, never tmux's argv (issue #68)", (t) => {
process.env.DASHBOARD_DATA_DIR = TMP;
t.after(() => delete process.env.DASHBOARD_DATA_DIR);
// tmux refuses a message past 16 KB (`command too long`).
const prompt = `-${"x".repeat(20_000)}\nend`;
const call = captureNewSession(() =>
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", initialPrompt: prompt })
);
assert.ok(Buffer.byteLength(call.join("")) < 8 * 1024);
const argv = paneArgv(call);
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
const file = argv[4];
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
assert.equal(argv[5], "claude");
// Run the reader with a stand-in CLI: the prompt arrives as ONE last
// argument, leading-dash guard included, and the file is gone after.
const { execFileSync } = require("node:child_process");
const out = execFileSync(argv[0], [
...argv.slice(1, 5),
process.execPath,
"-e",
"process.stdout.write(process.argv.at(-1))",
]).toString();
assert.equal(out, ` ${prompt}`);
assert.ok(!fs.existsSync(file));
});
it("refuses a prompt no CLI can take as one argument, before starting anything", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
assert.throws(
() => pty.spawnRun({ laneId: 16, cwd: "/tmp/repo", initialPrompt: "é".repeat(70_000) }),
{ code: "EBADPROMPT" }
);
assert.equal(calls.length, 0);
});
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
// A claude drawn at tmux's birth size duplicated its banner on the first
// resize; the argv rides as positional parameters, never script text.
+13 -1
View File
@@ -2,7 +2,8 @@
* @file Tests Quit AgentHub (issue #65, server/lib/quit.js and
* POST /api/settings/quit): the stopped flag, stopping through the service
* manager when a unit of ours runs the server (a SIGTERM would be restarted),
* and the route killing every lane pane before it stops. tmux runs only in the
* and the route killing every lane pane before it stops; the uninstall route's
* confirm gate. tmux runs only in the
* isolated server `npm run test:server` sets up.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -127,5 +128,16 @@ describe(
execFileSync("tmux", ["kill-session", "-t", "not-ours"]);
}
});
// Never with `confirm: true` here: that runs scripts/uninstall.js for real.
it("refuses an uninstall without confirm: true", async () => {
const res = await fetch(`${base}/api/settings/uninstall`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
});
assert.equal(res.status, 400);
assert.equal((await res.json()).error.code, "ECONFIRM");
});
}
);
+25 -4
View File
@@ -20,9 +20,9 @@ process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const scope = require("../lib/scope");
const member = (id, username = `u${id}`) => ({ user: { id, username, role: "member" } });
const member = (id, username = `u${id}`) => ({ user: { id, username, role: "developer" } });
const admin = { user: { id: 99, username: "boss", role: "admin" } };
const analyst = { user: { id: 98, username: "ana", role: "analyst" } };
const analyst = { user: { id: 98, username: "ana", role: "analyst" }, method: "GET" };
const local = {};
before(() => {
@@ -30,6 +30,7 @@ before(() => {
lane.run("/w/one", 1); // id 1, user 1
lane.run("/w/two", 2); // id 2, user 2
lane.run("/w/host", null); // id 3, nobody's
lane.run("/w/ana", 98); // id 4, the analyst's
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
);
@@ -63,6 +64,16 @@ describe("scope", () => {
assert.deepEqual(visibleSessions(member(3)), []);
});
it("limits an analyst's writes to their own workspaces", () => {
const write = { ...analyst, method: "POST" };
assert.equal(scope.memberId(analyst), null);
assert.equal(scope.memberId(write), 98);
assert.equal(scope.canSeeLane(write, 2), false);
assert.equal(scope.canSeeLane(write, 4), true);
assert.equal(scope.canSeeRun(write, "agenthub-lane-1-2"), false);
assert.deepEqual(visibleSessions(write), []);
});
it("shows admin, analyst and local mode everything", () => {
for (const req of [admin, analyst, local]) {
assert.equal(visibleSessions(req).length, 5);
@@ -183,12 +194,22 @@ describe("scopeGuard", () => {
);
});
it("lets admin, analyst and local requests through", () => {
for (const user of [admin.user, analyst.user, undefined]) {
it("lets admin and local requests through", () => {
for (const user of [admin.user, undefined]) {
assert.equal(run({ user, path: "/sessions/s2" }), 200);
assert.equal(run({ user, path: "/run", method: "POST", body: { laneId: 2 } }), 200);
}
});
it("lets an analyst read every row but write only their own", () => {
const user = analyst.user;
assert.equal(run({ user, path: "/sessions/s2" }), 200);
assert.equal(run({ user, path: "/lanes/2", method: "HEAD" }), 200);
assert.equal(run({ user, path: "/run", method: "POST", body: { laneId: 2 } }), 404);
assert.equal(run({ user, path: "/sessions/s2", method: "PATCH" }), 404);
assert.equal(run({ user, path: "/lanes/2", method: "DELETE" }), 404);
assert.equal(run({ user, path: "/lanes/4", method: "DELETE" }), 200);
});
});
describe("broadcastAudience", () => {
@@ -58,8 +58,8 @@ const SESSIONS = [
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
const mia = await auth.createUser("mia", "member-pass-1", "developer");
const max = await auth.createUser("max", "member-pass-2", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
+45 -6
View File
@@ -55,7 +55,7 @@ before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("mia", "member-pass-1", "member");
await auth.createUser("mia", "member-pass-1", "developer");
await auth.createUser("ana", "analyst-pass-1", "analyst");
});
@@ -73,6 +73,10 @@ describe("local mode (the default)", () => {
assert.equal((await call("GET", "/api/sessions")).status, 200);
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "local" });
assert.equal((await call("POST", "/api/auth/login", { body: {} })).status, 404);
// The one local viewer keeps their prefs too.
const chat = { prefs: { "agenthub.chatUi": "1" } };
assert.equal((await call("PUT", "/api/me/prefs", { body: chat })).status, 200);
assert.equal((await call("GET", "/api/me/prefs")).body.prefs["agenthub.chatUi"], "1");
});
});
@@ -101,15 +105,25 @@ describe("server mode", () => {
assert.match(ok.setCookie, /agenthub_sid=[^;]+; Path=\/; HttpOnly; SameSite=Strict/);
const cookie = ok.setCookie.split(";")[0];
assert.equal((await call("GET", "/api/sessions", { cookie })).status, 200);
assert.equal((await call("GET", "/api/auth/me", { cookie })).body.user.role, "member");
assert.equal((await call("GET", "/api/auth/me", { cookie })).body.user.role, "developer");
});
it("keeps an analyst read-only and the host's config writes an admin's", async () => {
it("lets an analyst work but keeps the host's config writes an admin's", async () => {
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal((await call("GET", "/api/sessions", { cookie: analyst })).status, 200);
// Past the role gate: the route itself answers the empty body.
const w = await call("POST", "/api/lanes/ensure", { cookie: analyst, body: {} });
assert.equal(w.status, 403);
assert.equal(w.body.error.code, "EREADONLY");
assert.ok(![401, 403].includes(w.status), `got ${w.status}`);
for (const [method, url] of [
["PUT", "/api/settings/no-such-route"],
["POST", "/api/settings/quit"],
["POST", "/api/alerts/rules"],
["DELETE", "/api/alerts/rules/x"],
]) {
const r = await call(method, url, { cookie: analyst, body: {} });
assert.equal(r.status, 403, `${method} ${url}`);
assert.equal(r.body.error.code, "EADMIN");
}
const member = await loginAs("mia", "member-pass-1");
// A path no router answers: the guard decides before any handler could
@@ -149,6 +163,30 @@ describe("server mode", () => {
auth.setDisabled("mia", false);
});
it("keeps each user's UI prefs apart, and only for a logged-in caller", async () => {
assert.equal((await call("GET", "/api/me/prefs")).status, 401);
const boss = await loginAs("boss", "admin-pass-1");
const ana = await loginAs("ana", "analyst-pass-1");
const on = { prefs: { "agenthub.chatUi": "1" } };
const put = await call("PUT", "/api/me/prefs", { cookie: boss, body: on });
assert.deepEqual(put.body.prefs, { "agenthub.chatUi": "1" });
assert.equal(
(await call("GET", "/api/me/prefs", { cookie: ana })).body.prefs["agenthub.chatUi"],
undefined
);
for (const prefs of [{ "agenthub.chatUi": true }, { userKey: "u1" }]) {
const bad = await call("PUT", "/api/me/prefs", { cookie: ana, body: { prefs } });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADPREFS");
}
// null removes the key: back to the default.
await call("PUT", "/api/me/prefs", {
cookie: boss,
body: { prefs: { "agenthub.chatUi": null } },
});
assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: boss })).body.prefs, {});
});
it("checks a WebSocket upgrade's login and Origin", async () => {
const cookie = await loginAs("boss", "admin-pass-1");
const req = (headers, url = "/ws") => ({ url, headers: { host: "127.0.0.1:1", ...headers } });
@@ -157,9 +195,10 @@ describe("server mode", () => {
assert.equal(auth.isWebSocketLoggedIn(req({ cookie, origin: "https://evil.example" })), false);
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal(auth.isWebSocketLoggedIn(req({ cookie: analyst })), true);
// Which runs an analyst may type into is the run check's (canSeeRun), not this one's.
assert.equal(
auth.isWebSocketLoggedIn(req({ cookie: analyst }, "/ws-pty/agenthub-lane-1")),
false
true
);
});
@@ -52,8 +52,8 @@ async function login(username, password) {
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
const mia = await auth.createUser("mia", "member-pass-1", "developer");
const max = await auth.createUser("max", "member-pass-2", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
laneMia = lane.run("/w/mia", mia.id).lastInsertRowid;
@@ -288,7 +288,7 @@ describe("server mode review fixes, second round", () => {
const scope = require("../lib/scope");
const a = scope.broadcastAudience("cc_config_changed", { type: "profile", name: "boss@x" });
assert.equal(a.strip, true);
assert.equal(scope.audienceIncludes(a, { id: 1, role: "member" }), true);
assert.equal(scope.audienceIncludes(a, { id: 1, role: "developer" }), true);
});
});
+13 -2
View File
@@ -50,8 +50,8 @@ const ALL_SESSIONS = ["sA", "sA2", "sB", "sHost"];
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const alice = await auth.createUser("alice", "alice-pass-1", "member");
const bob = await auth.createUser("bob", "bob-pass-1", "member");
const alice = await auth.createUser("alice", "alice-pass-1", "developer");
const bob = await auth.createUser("bob", "bob-pass-1", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
@@ -228,6 +228,17 @@ describe("server mode: list and aggregate routes", () => {
assert.equal(snap.status, 404);
});
it("tells an analyst which alerts they may acknowledge, and refuses the rest", async () => {
const feed = (await get("/api/alerts", "ana")).alerts;
assert.equal(feed.length, 5);
// ana owns no workspace here, so no alert is hers to acknowledge.
assert.ok(feed.every((a) => a.can_ack === false));
const bobs = feed.find((a) => a.session_id === "sB");
assert.equal((await call("POST", `/api/alerts/${bobs.id}/ack`, { as: "ana" })).status, 404);
// An admin acknowledges anything: no per-alert answer is needed.
assert.ok((await get("/api/alerts", "boss")).alerts.every((a) => !("can_ack" in a)));
});
it("lets a member acknowledge only their own alerts", async () => {
const r = await call("POST", "/api/alerts/ack-all", { as: "alice" });
assert.equal(r.status, 200);
@@ -56,7 +56,7 @@ before(async () => {
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.lane = db
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/w/ownerless', 'claude')")
.run().lastInsertRowid;
@@ -76,14 +76,14 @@ describe("managing accounts from the dashboard", () => {
body: {
username: "neo",
password: "neo-pass-123",
role: "member",
role: "developer",
currentPassword: BOSS_PASS,
},
});
assert.equal(r.status, 201);
assert.deepEqual(
{ username: r.body.user.username, role: r.body.user.role },
{ username: "neo", role: "member" }
{ username: "neo", role: "developer" }
);
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
assert.ok(await login("neo", "neo-pass-123"), "the new user can log in");
@@ -179,14 +179,14 @@ describe("managing accounts from the dashboard", () => {
assert.equal(bad.status, 400);
const none = await call("PATCH", "/api/users/9999", {
cookie: boss,
body: { role: "member", currentPassword: BOSS_PASS },
body: { role: "developer", currentPassword: BOSS_PASS },
});
assert.equal(none.status, 404);
});
it("never lets an admin demote or disable themselves", async () => {
const boss = await login("boss", BOSS_PASS);
for (const body of [{ role: "member" }, { disabled: true }]) {
for (const body of [{ role: "developer" }, { disabled: true }]) {
const r = await call("PATCH", `/api/users/${ids.boss}`, {
cookie: boss,
body: { ...body, currentPassword: BOSS_PASS },
@@ -239,7 +239,7 @@ describe("managing accounts from the dashboard", () => {
}
assert.equal(last.status, 429);
assert.equal(last.body.error.code, "ETOOMANY");
assert.equal(auth.findUser("mia").role, "member");
assert.equal(auth.findUser("mia").role, "developer");
});
});
+1 -1
View File
@@ -47,7 +47,7 @@ before(async () => {
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", "admin-pass-1", "admin")).id;
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
const lane = db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/mia', 'claude', ?)")
.run(ids.mia).lastInsertRowid;
@@ -64,8 +64,8 @@ function seedSession(id, laneId, skill, wfStatus) {
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
const mia = await auth.createUser("mia", "member-pass-1", "developer");
const max = await auth.createUser("max", "member-pass-2", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
@@ -63,8 +63,8 @@ before(async () => {
}
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const alice = await auth.createUser("alice", "alice-pass-1", "member");
const bob = await auth.createUser("bob", "bob-pass-1", "member");
const alice = await auth.createUser("alice", "alice-pass-1", "developer");
const bob = await auth.createUser("bob", "bob-pass-1", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
+249
View File
@@ -0,0 +1,249 @@
/**
* @file Tests Uninstall AgentHub (scripts/uninstall.js, `agenthub uninstall`,
* POST /api/settings/uninstall): on a throwaway HOME with every footprint in
* place, everything of AgentHub's goes and everything else stays — foreign
* hooks, other plugins and marketplaces, conversations, worktrees. Service
* manager, tmux, servers and the `claude` CLI are fakes; nothing real is touched.
* @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 fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-uninstall-"));
const CH = path.join(HOME, ".claude");
const saved = { ...process.env };
Object.assign(process.env, {
HOME,
CLAUDE_HOME: CH,
CODEX_HOME: path.join(HOME, ".codex"),
AGENTHUB_GEMINI_DIR: path.join(HOME, ".gemini"),
LANES_ROOT: path.join(CH, "agenthub-lanes"),
AGENTHUB_SETTINGS_FILE: path.join(HOME, ".config", "agenthub", "settings.env"),
TMPDIR: path.join(HOME, "tmp"),
});
delete process.env.DASHBOARD_DATA_DIR;
delete process.env.DASHBOARD_DB_PATH;
delete process.env.AGENTHUB_RUN_ID;
const uninstall = require("../../scripts/uninstall");
const codexHooks = require("../lib/codex-hooks");
const agyHooks = require("../lib/antigravity-hooks");
const p = (...parts) => path.join(HOME, ...parts);
const write = (file, body) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, typeof body === "string" ? body : JSON.stringify(body, null, 2));
};
const json = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const cmd = (c) => ({ hooks: [{ type: "command", command: c }] });
function fakeIo() {
const calls = [];
let sessions = ["agenthub-lane-1", "agenthub-lane-1-2", "work"];
let pids = [111];
const installed = path.join(CH, "plugins", "installed_plugins.json");
const known = path.join(CH, "plugins", "known_marketplaces.json");
return {
calls,
svc: {
label: "systemd --user service",
systemctlAvailable: () => true,
unitIsOurs: () => true,
unitPath: () => "/fake/agenthub.service",
uninstallUserService: () => (calls.push("service"), "removed"),
},
tmux: {
listSessions: (prefix) => sessions.filter((s) => s.startsWith(prefix)),
killSession: (n) => {
calls.push(`kill ${n}`);
sessions = sessions.filter((s) => s !== n);
},
},
livePids: () => pids,
stopDashboard: () => (calls.push("stop"), (pids = [])),
liveTray: () => null,
stopTray: () => {},
claude: () => "claude",
// Plays Claude Code: drops the record it was asked to drop.
run: (_c, args) => {
calls.push(args.join(" "));
if (args[1] === "uninstall") {
const d = json(installed);
delete d.plugins[args[2]];
write(installed, d);
} else {
const d = json(known);
delete d[args[3]];
write(known, d);
}
return { status: 0 };
},
kill: (pid) => {
if (pid !== 4242) throw new Error("ESRCH");
},
sleep: () => {},
};
}
before(() => {
fs.mkdirSync(p("tmp"), { recursive: true });
write(p(".claude", "settings.json"), {
hooks: {
PreToolUse: [cmd('node "/x/agenthub/scripts/hook-handler.js" PreToolUse'), cmd("their-hook")],
SessionStart: [cmd('node "/c/agenthub/v1/scripts/plugin-bootstrap.js"')],
},
statusLine: { type: "command", command: 'node "/x/agenthub/statusline/statusline.js"' },
theme: "dark",
});
write(p(".claude", "settings.json.agenthub-bak"), "{}");
write(p(".claude", "plugins", "installed_plugins.json"), {
plugins: {
"agenthub@sg": [{ scope: "user", installPath: p(".claude/plugins/cache/sg/agenthub/v1") }],
"agenthub-analytics@agenthub": [{ scope: "user" }],
"sg-all@sg": [{ scope: "user" }],
},
});
write(p(".claude", "plugins", "known_marketplaces.json"), { sg: {}, agenthub: {} });
write(p(".claude", "plugins", "cache", "sg", "agenthub", "v1", "package.json"), "{}");
write(p(".claude", "plugins", "cache", "sg", "sg-all", "v1", "package.json"), "{}");
write(p(".claude", "projects", "repo", "session.jsonl"), "{}\n");
write(p(".claude", ".agenthub.json"), "{}");
write(p(".claude", "agenthub-proxies", "a.json"), "{}");
write(p(".claude", "agenthub-git-credentials", "credentials.json"), "{}");
write(p(".claude", "agenthub", "dashboard.db"), "db");
write(p(".claude", "agenthub", "runtime", "state.json"), "{}");
write(p(".claude", "agenthub-lanes", ".state", "lane1", "run", "web.pid"), "4242");
write(p(".claude", "agenthub-lanes", "repo__feature", "README.md"), "user code");
write(p(".codex", "hooks.json"), { hooks: { Stop: [cmd("their-codex-hook")] } });
codexHooks.install();
write(p(".codex", "work.config.toml"), "# Written by AgentHub — AI Proxy profile work.\n");
write(p(".codex", "mine.config.toml"), "model = 'x'\n");
write(p(".codex", "sessions", "rollout-1.jsonl"), "{}\n");
agyHooks.install();
write(p(".local", "bin", "agenthub"), "#!/bin/sh\n# agenthub-plugin-launcher\n");
write(p(".config", "agenthub", "settings.env"), "CODEX_HOME=x\n");
});
after(() => {
process.env = saved;
fs.rmSync(HOME, { recursive: true, force: true });
});
describe("uninstall", () => {
it("plans every footprint and leaves the machine untouched", () => {
const io = fakeIo();
const keys = uninstall.plan(io).map((s) => s.key);
assert.deepEqual(keys, [
"panes",
"service",
"servers",
"stacks",
"claude-hooks",
"statusline",
"codex-hooks",
"antigravity-hooks",
"codex-proxies",
"plugin",
"files",
"data",
]);
assert.deepEqual(io.calls, []);
assert.ok(fs.existsSync(p(".claude", "agenthub", "dashboard.db")));
});
it("removes AgentHub's and keeps everything else", () => {
const io = fakeIo();
const r = uninstall.run({ io });
assert.equal(r.ok, true, JSON.stringify(r.steps));
// Order: panes before the unit before the server; plugin via the CLI.
assert.deepEqual(io.calls, [
"kill agenthub-lane-1",
"kill agenthub-lane-1-2",
"service",
"stop",
"plugin uninstall agenthub@sg -s user -y",
"plugin uninstall agenthub-analytics@agenthub -s user -y",
"plugin marketplace remove agenthub",
]);
const settings = json(p(".claude", "settings.json"));
assert.deepEqual(settings.hooks, { PreToolUse: [cmd("their-hook")] });
assert.equal(settings.statusLine, undefined);
assert.equal(settings.theme, "dark");
const codex = json(p(".codex", "hooks.json"));
assert.deepEqual(Object.keys(codex.hooks), ["Stop"]);
assert.ok(!fs.existsSync(p(".codex", "agenthub-hook.sh")));
assert.ok(!fs.existsSync(p(".codex", "work.config.toml")));
assert.ok(!fs.existsSync(p(".gemini", "config", "agenthub-hook.sh")));
for (const gone of [
".claude/agenthub",
".claude/agenthub-proxies",
".claude/agenthub-git-credentials",
".claude/.agenthub.json",
".claude/settings.json.agenthub-bak",
".claude/agenthub-lanes/.state",
".claude/plugins/cache/sg/agenthub",
".local/bin/agenthub",
".config/agenthub",
]) {
assert.ok(!fs.existsSync(p(gone)), `${gone} should be gone`);
}
for (const kept of [
".claude/projects/repo/session.jsonl",
".claude/agenthub-lanes/repo__feature/README.md",
".claude/plugins/cache/sg/sg-all",
".codex/mine.config.toml",
".codex/sessions/rollout-1.jsonl",
]) {
assert.ok(fs.existsSync(p(kept)), `${kept} should be kept`);
}
assert.deepEqual(Object.keys(json(p(".claude/plugins/known_marketplaces.json"))), ["sg"]);
assert.ok(fs.readFileSync(r.log, "utf8").includes("AgentHub uninstalled."));
});
it("finds nothing left on disk on a second run", () => {
const live = ["panes", "service", "servers"]; // the fakes' own processes
assert.deepEqual(
uninstall.plan(fakeIo()).filter((s) => !live.includes(s.key)),
[]
);
});
it("keeps the cache when Claude Code still records the plugin", () => {
write(p(".claude", "plugins", "installed_plugins.json"), {
plugins: { "agenthub@sg": [{ scope: "user" }] },
});
write(p(".claude", "plugins", "cache", "sg", "agenthub", "v2", "package.json"), "{}");
const io = { ...fakeIo(), run: () => ({ status: 1, stderr: "boom" }) };
const r = uninstall.run({ io });
assert.equal(r.ok, false);
assert.equal(r.steps.find((s) => s.key === "plugin").status, "failed");
assert.ok(fs.existsSync(p(".claude", "plugins", "cache", "sg", "agenthub", "v2")));
});
});
describe("isSafeDataDir", () => {
it("never deletes the home, CLAUDE_HOME, their parents or a stranger's folder", () => {
assert.equal(uninstall.isSafeDataDir(HOME), false);
assert.equal(uninstall.isSafeDataDir(CH), false);
assert.equal(uninstall.isSafeDataDir(path.dirname(HOME)), false);
assert.equal(uninstall.isSafeDataDir("/"), false);
assert.equal(uninstall.isSafeDataDir(p("Documents")), false);
assert.equal(uninstall.isSafeDataDir(p(".claude", "agenthub")), true);
});
});
describe("Claude hook matching", () => {
it("is AgentHub's handler or bootstrap only", () => {
assert.equal(uninstall.isOurHook(cmd('node "/a/scripts/hook-handler.js" Stop')), true);
assert.equal(uninstall.isOurHook(cmd('node "/a/scripts/plugin-bootstrap.js"')), true);
assert.equal(uninstall.isOurHook(cmd("their-hook")), false);
});
});
@@ -0,0 +1,63 @@
/**
* @file users-role-migration.test.js
* @description The `users` table migration that renamed the `member` role to
* `developer`: an existing database's members become developers, keep their
* logins, and the old value is refused afterwards.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { it, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const { DatabaseSync } = require("node:sqlite");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "role-migration-"));
const DB = path.join(TMP, "dashboard.db");
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
it("turns every member into a developer and keeps their logins", () => {
const old = new DatabaseSync(DB);
old.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('admin','analyst','member')),
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE auth_sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL
);
INSERT INTO users (username, role, password_hash) VALUES
('boss', 'admin', 'x'), ('mia', 'member', 'x'), ('ana', 'analyst', 'x');
INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES ('t', 2, '2999-01-01');
`);
old.close();
process.env.DASHBOARD_DB_PATH = DB;
const { db } = require("../db");
const roles = db.prepare("SELECT username, role FROM users ORDER BY id").all();
assert.deepEqual(
roles.map((r) => [r.username, r.role]),
[
["boss", "admin"],
["mia", "developer"],
["ana", "analyst"],
]
);
assert.equal(db.prepare("SELECT user_id FROM auth_sessions").get().user_id, 2);
assert.throws(() =>
db
.prepare("INSERT INTO users (username, role, password_hash) VALUES ('x', 'member', 'x')")
.run()
);
db.prepare("INSERT INTO users (username, password_hash) VALUES ('neo', 'x')").run();
assert.equal(db.prepare("SELECT role FROM users WHERE username = 'neo'").get().role, "developer");
});
+42 -3
View File
@@ -291,8 +291,8 @@ db.exec(`
cache_write_per_mtok REAL NOT NULL DEFAULT 0,
cache_write_1h_per_mtok REAL NOT NULL DEFAULT 0,
-- Fast mode (research preview) premium input/output rates; 0 = no fast pricing.
-- Cache rates in fast mode are derived from fast_input via the standard
-- caching multipliers (see server/lib/pricing-constants.js).
-- Cache rates in fast mode are the stored cache rates scaled by
-- fast_input / input (server/routes/pricing.js).
fast_input_per_mtok REAL NOT NULL DEFAULT 0,
fast_output_per_mtok REAL NOT NULL DEFAULT 0,
-- Rates for a request whose prompt is above LONG_CONTEXT_TOKENS (Haiku 5.5);
@@ -1746,7 +1746,7 @@ db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('admin','analyst','member')),
role TEXT NOT NULL DEFAULT 'developer' CHECK(role IN ('admin','analyst','developer')),
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
@@ -1768,8 +1768,47 @@ db.exec(`
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL
);
-- One account's own UI preferences (server/routes/me.js). \`local\` in local
-- mode, \`u<id>\` per server-mode account. The same table as the server-s3
-- work's (which adds theme, language… to PREF_KEYS) — keep the two identical.
CREATE TABLE IF NOT EXISTS user_prefs (
user_key TEXT PRIMARY KEY,
prefs TEXT NOT NULL DEFAULT '{}',
onboarded_at TEXT,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// The `member` role was renamed `developer`: rebuild `users` for the new
// CHECK, foreign keys off so `auth_sessions` (ON DELETE CASCADE) keeps every
// login through the DROP.
{
const meta = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='users'")
.get();
if (meta?.sql?.includes("'member'")) {
db.exec(`
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
role TEXT NOT NULL DEFAULT 'developer' CHECK(role IN ('admin','analyst','developer')),
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
INSERT INTO users_new (id, username, role, password_hash, disabled, created_at)
SELECT id, username, CASE role WHEN 'member' THEN 'developer' ELSE role END,
password_hash, disabled, created_at FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
}
migrationLock.exec("COMMIT");
migrationLock.close();
+4
View File
@@ -101,6 +101,7 @@ const locksRouter = require("./routes/locks");
const petsRouter = require("./routes/pets");
const snapshotsRouter = require("./routes/snapshots");
const authRouter = require("./routes/auth");
const meRouter = require("./routes/me");
const accountsRouter = require("./routes/accounts");
const usersRouter = require("./routes/users");
const { auditGuard } = require("./lib/audit");
@@ -131,6 +132,7 @@ function createApp() {
app.use("/api", scopeGuard);
app.use("/api", auditGuard);
app.use("/api/auth", authRouter);
app.use("/api/me", meRouter);
app.use("/api/accounts", accountsRouter);
app.use("/api/users", usersRouter);
@@ -448,6 +450,8 @@ function startBackgroundServices() {
// Scheduled reports (Settings → Reports). Off with DASHBOARD_REPORTS_MS=0.
require("./lib/reports").startReportScheduler();
// Daily database backup (Settings → Data → Backup). Off until a folder is set.
require("./lib/db-backup").startBackupScheduler();
// Rates for models seen in token_usage with no pricing row of their own.
require("./lib/pricing-sync").startPricingSync(require("./db").db);
+3 -2
View File
@@ -21,7 +21,8 @@ const SESSION_PATHS = [
function auditGuard(req, res, next) {
const u = req.user;
if (!u || u.role === "member" || (req.method !== "GET" && req.method !== "HEAD")) return next();
if (!u || u.role === "developer" || (req.method !== "GET" && req.method !== "HEAD"))
return next();
const p = require("./auth").apiPath(req);
for (const re of SESSION_PATHS) {
const m = re.exec(p);
@@ -32,7 +33,7 @@ function auditGuard(req, res, next) {
for (const owner of sessionOwners(id)) {
if (owner === u.id) continue;
const ownerRole = sql("SELECT role FROM users WHERE id = ?").get(owner)?.role;
if (ownerRole !== "member") continue;
if (ownerRole !== "developer") continue;
sql(
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
).run(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
+14 -19
View File
@@ -11,9 +11,10 @@
* disabled user's sessions stop working at once (the lookup joins `users`).
* - `authGuard` (mounted on `/api`) lets through `/health` and `/auth/*`, the
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
* its own bearer token), and everything else only with a valid login. An
* `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host
* account's own configuration — are an `admin`'s.
* its own bearer token), and everything else only with a valid login.
* Writes under `ADMIN_WRITE_PREFIXES` — the host account's own
* configuration — are an `admin`'s; an `analyst` and a `developer` write
* only their own rows (`scope.js`).
* - Accounts are made from the host's shell (`agenthub users …`) or by an
* admin on the Users page (`server/routes/users.js`), where every change
* needs the admin's own password again (`confirmPassword`, throttled).
@@ -36,7 +37,7 @@ const scrypt = promisify(crypto.scrypt);
const COOKIE = "agenthub_sid";
const SESSION_DAYS = 30;
const ROLES = ["admin", "analyst", "member"];
const ROLES = ["admin", "analyst", "developer"];
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const MIN_PASSWORD = 8;
@@ -58,13 +59,15 @@ const ADMIN_WRITE_PREFIXES = [
"/reports",
"/pets",
"/users",
// Alert rules and budgets fire for every user's sessions.
"/alerts/rules",
];
/**
* API prefixes a member may not reach at all, reads included: the host's own
* API prefixes a developer may not reach at all, reads included: the host's own
* configuration and logs, and every view across all users (export, metrics,
* reports, webhook deliveries, remote machines, imports). Admin and analyst
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a member's own work needs.
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a developer's own work needs.
*/
const STAFF_ONLY_PREFIXES = [
"/cc-config",
@@ -131,7 +134,7 @@ function findUser(username) {
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
}
async function createUser(username, password, role = "member") {
async function createUser(username, password, role = "developer") {
const name = validateUsername(username);
validatePassword(password);
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
@@ -392,7 +395,7 @@ function authGuard(req, res, next) {
const under = (x) => underPrefix(p, x);
const isRead = req.method === "GET" || req.method === "HEAD";
if (
user.role === "member" &&
user.role === "developer" &&
STAFF_ONLY_PREFIXES.some(under) &&
!(isRead && MEMBER_READ_ALLOW.some(under))
) {
@@ -407,8 +410,7 @@ function authGuard(req, res, next) {
if (raw && !isLoopback(raw) && !isOwnHost(raw, req)) {
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
}
if (user.role === "analyst")
return deny(res, 403, "EREADONLY", "an analyst account is read-only");
// An analyst writes like a developer: only their own rows (scope.js).
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
return deny(res, 403, "EADMIN", "only an admin may change this");
}
@@ -432,15 +434,8 @@ function isWebSocketLoggedIn(req) {
}
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
}
const user = userFromRequest(req);
if (
user &&
user.role === "analyst" &&
/^\/ws-pty\//.test(new URL(req.url, "http://x").pathname)
) {
return false; // a terminal is a write
}
return Boolean(user);
// Which terminals a login may open is the run check's (websocket.js).
return Boolean(userFromRequest(req));
}
module.exports = {
-5
View File
@@ -36,10 +36,6 @@
const EXPORT_FORMAT = "agenthub-export";
const EXPORT_VERSION = 1;
// Tables serialized into the bundle. Order matters for restore (parents before
// children); FK checks are deferred to COMMIT anyway (see importExportBundle).
const SESSION_CHILD_TABLES = ["agents", "events", "token_usage", "workflows"];
/**
* Build the full export bundle from the live database.
*
@@ -263,7 +259,6 @@ module.exports = {
exportableRun,
EXPORT_FORMAT,
EXPORT_VERSION,
SESSION_CHILD_TABLES,
buildExportBundle,
importExportBundle,
ImportFormatError,
+221
View File
@@ -0,0 +1,221 @@
/**
* @file Scheduled database backups (Settings → Data → Backup). Once a day, at
* a chosen local hour of the server, the dashboard's SQLite database is copied
* into a folder the admin picked: `VACUUM INTO` on a READ-ONLY connection in a
* worker thread — a consistent snapshot that writes from other connections
* never restart, compacted (1.25 GB → ~0.7 GB), while the event loop keeps
* serving (≈30 s at that size). The file name is server-made,
* `agenthub-YYYYMMDD-HHMMSS.db`, written as `<name>.partial` and renamed once
* complete. Rotation deletes ONLY regular files matching that exact name, oldest
* first beyond `keep` — nothing else in the folder, never through a symlink.
* Settings live in the settings file: AGENTHUB_BACKUP_DIR (empty = off),
* AGENTHUB_BACKUP_HOUR (0–23, default 2), AGENTHUB_BACKUP_KEEP (1–365, default
* 7). A missed day (server down at the hour) runs at the next start; a failed
* one is retried the next day or by "Back up now", never every minute. The
* scheduler ticks every DASHBOARD_BACKUP_TICK_MS (default 60 s, 0 = off).
* Restoring is manual: stop AgentHub, copy a backup over the database, start.
* This file is also the worker's entry point.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
const KEYS = {
dir: "AGENTHUB_BACKUP_DIR",
hour: "AGENTHUB_BACKUP_HOUR",
keep: "AGENTHUB_BACKUP_KEEP",
};
const NAME_RE = /^agenthub-(\d{8})-(\d{6})\.db$/;
const coded = (message, code) => Object.assign(new Error(message), { code });
function int(value, lo, hi, fallback) {
const n = Number(value);
return value !== undefined && value !== "" && Number.isInteger(n) && n >= lo && n <= hi
? n
: fallback;
}
function backupConfig(env = process.env) {
return {
dir: env[KEYS.dir] || "",
hour: int(env[KEYS.hour], 0, 23, 2),
keep: int(env[KEYS.keep], 1, 365, 7),
};
}
/** Absolute, existing, writable directory → its resolved path; else INVALID_PATH. */
function validateBackupDir(input) {
const invalid = (message) => coded(message, "INVALID_PATH");
const trimmed = String(input).trim();
if (/[\x00-\x1f]/.test(trimmed)) throw invalid("the folder may not contain control characters");
const expanded = trimmed.replace(/^~(?=$|\/)/, os.homedir());
if (!path.isAbsolute(expanded)) throw invalid("the backup folder must be an absolute path");
const resolved = path.resolve(expanded);
let st;
try {
st = fs.statSync(resolved);
} catch {
throw invalid(`Directory does not exist: ${resolved}`);
}
if (!st.isDirectory()) throw invalid(`Not a directory: ${resolved}`);
try {
fs.accessSync(resolved, fs.constants.W_OK);
} catch {
throw invalid(`AgentHub cannot write to ${resolved}`);
}
return resolved;
}
/** Local `YYYYMMDD-HHMMSS`. */
function stamp(d) {
const p = (n) => String(n).padStart(2, "0");
return (
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
);
}
/** AgentHub's own backups in `dir`, newest first. Symlinks and look-alikes are not ours. */
function listBackups(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isFile() && NAME_RE.test(e.name))
.map((e) => {
const st = fs.statSync(path.join(dir, e.name));
return { name: e.name, size: st.size, mtime: st.mtime.toISOString() };
})
.sort((a, b) => (a.name < b.name ? 1 : -1));
}
/** Delete our backups beyond the newest `keep`; returns the names removed. */
function rotate(dir, keep) {
const old = listBackups(dir).slice(keep);
for (const f of old) fs.unlinkSync(path.join(dir, f.name));
return old.map((f) => f.name);
}
/** Whether the scheduler should back up now. `attemptedDay` = YYYYMMDD already tried. */
function dueNow(now, cfg, files, attemptedDay) {
if (!cfg.dir || now.getHours() < cfg.hour) return false;
const today = stamp(now).slice(0, 8);
if (attemptedDay === today) return false;
return !files.some((f) => NAME_RE.exec(f.name)?.[1] === today);
}
function vacuumInto(dbPath, out) {
return new Promise((resolve, reject) => {
const w = new Worker(__filename, { workerData: { agenthubBackupWorker: true, dbPath, out } });
w.once("message", (m) => (m.error ? reject(coded(m.error.message, m.error.code)) : resolve()));
w.once("error", reject);
w.once(
"exit",
(code) => code !== 0 && reject(coded(`backup worker exited (${code})`, "EBACKUP"))
);
});
}
let running = null;
let last = null;
function runBackup() {
if (running) return running;
running = (async () => {
const { dir: configured, keep } = backupConfig();
if (!configured) throw coded("no backup folder is set", "ENOBACKUPDIR");
const started = new Date();
let partial = null;
try {
const dir = validateBackupDir(configured); // it may have been unmounted since
const name = `agenthub-${stamp(started)}.db`;
partial = path.join(dir, `${name}.partial`);
await vacuumInto(require("../db").DB_PATH, partial);
fs.renameSync(partial, path.join(dir, name));
partial = null;
const removed = rotate(dir, keep);
const size = fs.statSync(path.join(dir, name)).size;
last = {
at: started.toISOString(),
ok: true,
file: name,
size,
ms: Date.now() - started,
removed,
};
return last;
} catch (err) {
if (partial) fs.rmSync(partial, { force: true });
last = { at: started.toISOString(), ok: false, error: err.message };
throw err;
}
})().finally(() => {
running = null;
});
return running;
}
const isRunning = () => running !== null;
const lastResult = () => last;
function startBackupScheduler() {
const every = Number(process.env.DASHBOARD_BACKUP_TICK_MS ?? 60_000);
if (!Number.isFinite(every) || every <= 0) return;
let attemptedDay = null;
const t = setInterval(() => {
const cfg = backupConfig();
if (!cfg.dir || running) return;
const now = new Date();
let files = [];
try {
files = listBackups(cfg.dir);
} catch {
/* folder gone: runBackup reports it */
}
if (!dueNow(now, cfg, files, attemptedDay)) return;
attemptedDay = stamp(now).slice(0, 8);
runBackup().then(
(r) => console.log(`[backup] ${r.file} (${(r.size / 1048576).toFixed(0)} MB, ${r.ms} ms)`),
(err) => console.warn(`[backup] failed: ${err.message}`)
);
}, every);
if (t.unref) t.unref();
}
if (!isMainThread && workerData?.agenthubBackupWorker) {
// Same driver order as server/db.js: better-sqlite3, else node:sqlite.
let conn;
try {
try {
const Database = require("better-sqlite3");
conn = new Database(workerData.dbPath, { readonly: true });
} catch {
const Database = require("../compat-sqlite");
conn = new Database(workerData.dbPath, { readonly: true });
}
conn.pragma("busy_timeout = 5000");
conn.prepare("VACUUM INTO ?").run(workerData.out);
parentPort.postMessage({ ok: true });
} catch (err) {
parentPort.postMessage({ error: { message: err.message, code: err.code || "EBACKUP" } });
} finally {
conn?.close();
}
}
module.exports = {
KEYS,
backupConfig,
validateBackupDir,
listBackups,
rotate,
dueNow,
runBackup,
isRunning,
lastResult,
startBackupScheduler,
};
+1 -13
View File
@@ -7,14 +7,6 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// ── Prompt-caching multipliers (relative to base input price) ───────────────
// Stored model rates already encode these for the standard tier, but fast-mode
// cache rates are derived from the fast input base using the same ratios, so we
// keep the multipliers here for that derivation and for documentation.
const CACHE_READ_MULTIPLIER = 0.1; // cache hit / refresh
const CACHE_WRITE_5M_MULTIPLIER = 1.25; // 5-minute ephemeral write
const CACHE_WRITE_1H_MULTIPLIER = 2.0; // 1-hour ephemeral write
// ── Cross-cutting rate modifiers ────────────────────────────────────────────
const DATA_RESIDENCY_US_MULTIPLIER = 1.1; // inference_geo === "us"
const BATCH_DISCOUNT_MULTIPLIER = 0.5; // service_tier === "batch" (50% off)
@@ -26,7 +18,7 @@ const LONG_CONTEXT_TOKENS = 100_000;
// ── Server-tool surcharges (billed in addition to tokens) ───────────────────
const WEB_SEARCH_PER_1K_SEARCHES = 10.0; // $10 per 1,000 web_search_requests
const WEB_FETCH_PER_REQUEST = 0.0; // web fetch has no surcharge — tokens only
// Web fetch has no surcharge — tokens only.
// Code execution: billed by container-time, not request count. Transcripts only
// expose request counts, so we estimate at the documented 5-minute minimum per
@@ -48,14 +40,10 @@ function estimateCodeExecHours(codeExecRequests, webSearchRequests, webFetchRequ
}
module.exports = {
CACHE_READ_MULTIPLIER,
CACHE_WRITE_5M_MULTIPLIER,
CACHE_WRITE_1H_MULTIPLIER,
DATA_RESIDENCY_US_MULTIPLIER,
BATCH_DISCOUNT_MULTIPLIER,
LONG_CONTEXT_TOKENS,
WEB_SEARCH_PER_1K_SEARCHES,
WEB_FETCH_PER_REQUEST,
CODE_EXEC_PER_HOUR,
CODE_EXEC_MIN_MINUTES,
CODE_EXEC_FREE_HOURS,
+53 -6
View File
@@ -6,8 +6,12 @@
* both directions; text WS frames carry small JSON control messages
* (`resize`, and an outbound `exit` sent once when the pane process/tmux
* session ends). Multiple browser tabs each get their own PTY attach
* process — tmux itself is what keeps them all in sync, this module does no
* cross-connection coordination.
* process, and ONE of them sizes the window: the DRIVER — the first to attach,
* then whichever sent `{type:"resize", drive:true}` last (the client does on a
* click or a keystroke). Every other attach is a follower: its PTY is held at
* the driver's size and told so (`{type:"size", cols, rows}`) so its xterm
* renders the same grid, scaled to fit, instead of each browser resizing tmux
* under the others. A driver that leaves sends `{type:"lead"}` to the rest.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -61,6 +65,28 @@ function attachBinary(platform = process.platform) {
}
}
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
const groups = new Map();
const tell = (v, msg) => {
try {
v.ws.send(JSON.stringify(msg), { binary: false });
} catch {
/* client gone — its close handler cleans up */
}
};
/** `v` sizes the window now: every other attach is held at its size and told. */
function drive(group, v) {
group.driver = v;
v.pty.resize(v.cols, v.rows);
for (const o of group.viewers) {
if (o === v) continue;
o.pty.resize(v.cols, v.rows);
tell(o, { type: "size", cols: v.cols, rows: v.rows });
}
}
/**
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
* `tmux attach-session -t <runId>` per call.
@@ -75,11 +101,20 @@ function attach(ws, runId, { cols, rows }) {
} catch {
/* session gone or tmux unavailable — attach below will surface that */
}
let group = groups.get(runId);
if (!group) groups.set(runId, (group = { driver: null, viewers: new Set() }));
const lead = group.driver;
// A follower attaches at the driver's size: tmux sizes the window to the
// newest client, so its own size would resize the driver's pane first.
const pty = spawnImpl(attachBinary(), ["attach-session", "-t", tmux.session(runId)], {
name: "xterm-256color",
cols: cols || 80,
rows: rows || 24,
cols: lead ? lead.cols : cols || 80,
rows: lead ? lead.rows : rows || 24,
});
const me = { ws, pty, cols: cols || 80, rows: rows || 24 };
group.viewers.add(me);
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
else group.driver = me;
// Backpressure: a slow viewer (a remote browser on a thin link) must not
// queue a chatty pane's output in this process without bound. Past 1 MB
@@ -143,7 +178,11 @@ function attach(ws, runId, { cols, rows }) {
return;
}
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
pty.resize(msg.cols, msg.rows);
me.cols = msg.cols;
me.rows = msg.rows;
// A follower's own size is remembered, not applied: its PTY stays at
// the driver's until it takes the lead.
if (msg.drive || group.driver === me || !group.driver) drive(group, me);
return;
}
}
@@ -151,6 +190,12 @@ function attach(ws, runId, { cols, rows }) {
});
ws.on("close", () => {
group.viewers.delete(me);
if (!group.viewers.size) groups.delete(runId);
else if (group.driver === me) {
group.driver = null;
for (const o of group.viewers) tell(o, { type: "lead" });
}
try {
pty.kill();
} catch {
@@ -173,4 +218,6 @@ function attach(ws, runId, { cols, rows }) {
// resolves a working native binding with no compiler needed.
__setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl };
const __resetGroups = () => groups.clear();
module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl, __resetGroups };
+35 -2
View File
@@ -30,6 +30,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const tmux = require("./tmux");
const { resolveProxy, proxyForSpawn } = require("./proxy-apply");
const { desktopEnv } = require("./desktop-env");
@@ -137,6 +139,23 @@ function promptArg(p) {
return p.startsWith("-") ? ` ${p}` : p;
}
// A prompt past INLINE_PROMPT_BYTES reaches the pane in a 0600 file, not in
// tmux's argv: one tmux message holds 16 KB with the env and the rest of the
// argv beside it (`command too long`, issue #68). The pane's own sh reads it
// back as the CLI's last argument and deletes the file. No CLI can take one
// argument past Linux's MAX_ARG_STRLEN (128 KB, E2BIG), so that is refused.
const INLINE_PROMPT_BYTES = 4 * 1024;
const MAX_PROMPT_BYTES = 128 * 1024 - 2; // the NUL, and promptArg's space
const READ_PROMPT = 'p=$(cat "$1") && rm -f "$1" && shift && exec "$@" "$p"';
function promptFileArgv(id, argv) {
const dir = path.join(require("./claude-home").getDataDir(), "run-prompts");
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = path.join(dir, `${id}.txt`);
fs.writeFileSync(file, argv[argv.length - 1], { mode: 0o600 });
return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)];
}
function buildClaudeArgv({
model,
permissionMode,
@@ -316,6 +335,12 @@ function spawnRun(args) {
if (typeof cwd !== "string" || !cwd) {
throw makeErr("EBADCWD", "cwd is required");
}
if (typeof initialPrompt === "string" && Buffer.byteLength(initialPrompt) > MAX_PROMPT_BYTES) {
throw makeErr(
"EBADPROMPT",
`the prompt is ${Math.ceil(Buffer.byteLength(initialPrompt) / 1024)} KB; an AI CLI takes at most 128 KB as its first message — attach the text as a file instead`
);
}
// A lane whose tool has since left the catalog keeps its row; it just
// cannot start until the catalog knows the tool again.
const entry = aiTools.get(tool);
@@ -434,6 +459,14 @@ function spawnRun(args) {
entry
);
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
// Every launcher puts the prompt LAST; Windows has no sh to read it back.
const paneCmd =
initialPrompt &&
!pasteLater &&
process.platform !== "win32" &&
Buffer.byteLength(argv[argv.length - 1]) > INLINE_PROMPT_BYTES
? promptFileArgv(id, argv)
: argv;
const record = () => {
if (!dashboardRuns) return;
@@ -524,7 +557,7 @@ function spawnRun(args) {
};
if (!tmux.hasSession(id)) {
tmux.newSession({ name: id, cwd, argv: paneArgv(argv), env });
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
record();
schedulePaste();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
@@ -534,7 +567,7 @@ function spawnRun(args) {
// `--resume` and an initial prompt would never be typed, while the API
// still answered 200. Run the argv in the pane the user already sees
// instead of erroring or opening a second session.
tmux.sendCommand(id, argv, env, cwd);
tmux.sendCommand(id, paneCmd, env, cwd);
record();
schedulePaste();
}
+14 -7
View File
@@ -6,9 +6,11 @@
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
* that workspace holding it (a session not stamped yet). Agents, events and
* token rows belong to their session. A member sees only what their own
* workspaces own; an admin and an analyst see everything, and so does every
* request in local mode (`req.user` is never set there).
* token rows belong to their session. A developer sees and changes only what
* their own workspaces own; an analyst SEES everything but changes only their
* own (every non-GET request is scoped like a developer's); an admin sees and
* changes everything, and so does every request in local mode (`req.user` is
* never set there).
*
* Every helper takes `req`, so no caller can pick whose rows it reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -16,10 +18,14 @@
const sql = (q) => require("../db").db.prepare(q);
/** The member whose rows this request is limited to, or null for "all". */
/** The user whose rows this request is limited to, or null for "all". */
function memberId(req) {
const u = req?.user;
return u && u.role === "member" ? u.id : null;
if (!u) return null;
if (u.role === "developer") return u.id;
// An analyst reads every user's rows but writes only their own.
if (u.role === "analyst" && req.method !== "GET" && req.method !== "HEAD") return u.id;
return null;
}
/** The owner a workspace created by this request gets (null outside server mode). */
@@ -191,7 +197,8 @@ function namedIds(req) {
/**
* Express middleware (on `/api`, after authGuard): a member naming a row that
* is not theirs — in the path, the query or the body — gets 404, the same
* answer as a row that does not exist. A no-op for admin, analyst and local.
* answer as a row that does not exist. A no-op for an admin, an analyst's
* reads, and local mode.
*/
function scopeGuard(req, res, next) {
if (memberId(req) == null) return next();
@@ -273,7 +280,7 @@ function broadcastAudience(type, data) {
/** Whether a client logged in as `user` (undefined in local mode) gets it. */
function audienceIncludes(audience, user) {
if (!user || user.role !== "member") return true;
if (!user || user.role !== "developer") return true;
if (audience === null || audience.strip) return true;
return Boolean(audience.owners && audience.owners.has(user.id));
}
+1 -1
View File
@@ -37,7 +37,7 @@ function statusClause(filter, alias = "s") {
// A pure SQL builder: scope.js opens the db only for its canSee* checks,
// so this also runs in the read worker on its own connection.
const w = require("./scope").sessionWhere(
{ user: { id: filter.member, role: "member" } },
{ user: { id: filter.member, role: "developer" } },
`${alias}.id`,
`${alias}.lane_id`
);
+12 -8
View File
@@ -5,7 +5,7 @@
* - POST /api/auth/logout (Auth)
* - GET /api/accounts (Auth) — the Claude account pool, no tokens
* - GET /api/users (Auth) — every account's usage (admin/analyst)
* - GET /api/users/audit (Auth) — staff reads of members' sessions
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
* Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -26,7 +26,7 @@ const schemas = {
properties: {
id: { type: "integer" },
username: { type: "string" },
role: { type: "string", enum: ["admin", "analyst", "member"] },
role: { type: "string", enum: ["admin", "analyst", "developer"] },
disabled: { type: "boolean" },
},
},
@@ -84,7 +84,7 @@ const paths = {
tags: ["Auth"],
operationId: "listServerUsers",
summary:
"Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a member)",
"Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a developer)",
responses: {
200: {
description: "Accounts",
@@ -121,7 +121,7 @@ const paths = {
},
},
},
403: err("EADMIN — a member"),
403: err("EADMIN — a developer"),
},
},
post: {
@@ -139,7 +139,11 @@ const paths = {
properties: {
username: { type: "string" },
password: { type: "string", minLength: 8 },
role: { type: "string", enum: ["admin", "analyst", "member"], default: "member" },
role: {
type: "string",
enum: ["admin", "analyst", "developer"],
default: "developer",
},
currentPassword: { type: "string" },
},
},
@@ -163,7 +167,7 @@ const paths = {
"Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)",
parameters: [idParam],
requestBody: changeBody({
role: { type: "string", enum: ["admin", "analyst", "member"] },
role: { type: "string", enum: ["admin", "analyst", "developer"] },
disabled: { type: "boolean" },
}),
responses: changeResponses,
@@ -193,7 +197,7 @@ const paths = {
get: {
tags: ["Auth"],
operationId: "listAuditLog",
summary: "Admin/analyst reads of sessions members own, newest first",
summary: "Admin/analyst reads of sessions developers own, newest first",
parameters: [{ name: "limit", in: "query", schema: { type: "integer", maximum: 1000 } }],
responses: {
200: {
@@ -223,7 +227,7 @@ const paths = {
},
},
},
403: err("EADMIN — a member"),
403: err("EADMIN — a developer"),
},
},
},
+105
View File
@@ -8,6 +8,9 @@
* - PUT /api/settings/autostart (Settings)
* - GET /api/settings/tray (Settings)
* - PUT /api/settings/tray (Settings)
* - GET /api/settings/backup (Settings)
* - PUT /api/settings/backup (Settings)
* - POST /api/settings/backup/run (Settings)
* - GET /api/workflows/runs (Workflows)
* - GET /api/workflows/runs/{runId} (Workflows)
* - GET /api/remote-sources (Remote Sources)
@@ -1120,6 +1123,108 @@ const paths = {
};
})(),
"/api/settings/backup": (() => {
const state = {
description: "Daily database backup settings, the folder's backups and the last run",
content: {
"application/json": {
schema: {
type: "object",
required: ["dir", "hour", "keep", "running", "files", "env_owned"],
properties: {
dir: { type: "string", description: "Backup folder; empty = backups off" },
hour: { type: "integer", minimum: 0, maximum: 23, description: "Server local hour" },
keep: { type: "integer", minimum: 1, maximum: 365 },
running: { type: "boolean" },
last: {
type: "object",
nullable: true,
description:
"This process's last run: {at, ok, file?, size?, ms?, removed?, error?}",
},
files: {
type: "array",
description: "AgentHub's own `agenthub-YYYYMMDD-HHMMSS.db` files, newest first",
items: {
type: "object",
properties: {
name: { type: "string" },
size: { type: "integer" },
mtime: { type: "string", format: "date-time" },
},
},
},
dir_error: { type: "string", nullable: true },
env_owned: {
type: "object",
properties: {
dir: { type: "boolean" },
hour: { type: "boolean" },
keep: { type: "boolean" },
},
},
},
},
},
},
};
return {
get: {
tags: ["Settings"],
summary: "Daily database backup settings and backups",
operationId: "getBackup",
responses: { 200: state },
},
put: {
tags: ["Settings"],
summary: "Set the backup folder, hour and number kept",
description:
"Any subset of the three fields, persisted to the settings file as AGENTHUB_BACKUP_DIR / AGENTHUB_BACKUP_HOUR / AGENTHUB_BACKUP_KEEP. An empty `dir` turns backups off. Same-origin only.",
operationId: "setBackup",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
dir: { type: "string" },
hour: { type: "integer", minimum: 0, maximum: 23 },
keep: { type: "integer", minimum: 1, maximum: 365 },
},
},
},
},
},
responses: {
200: { ...state, description: "The new state" },
400: {
description:
"INVALID_PATH: the folder is relative, missing, not a directory or not writable; INVALID_INPUT: hour/keep out of range",
},
403: { description: "EBADORIGIN: another site asked" },
409: { description: "EENVOWNED: the environment set that field" },
500: { description: "ESAVE: the settings file could not be written" },
},
},
};
})(),
"/api/settings/backup/run": {
post: {
tags: ["Settings"],
summary: "Back up the database now",
description:
"Starts a backup and answers at once; poll GET /api/settings/backup while `running`. Same-origin only.",
operationId: "runBackup",
responses: {
202: { description: "Started (or already running); the backup state" },
400: { description: "ENOBACKUPDIR: no backup folder is set" },
403: { description: "EBADORIGIN: another site asked" },
},
},
},
"/api/settings/codex-hooks": (() => {
const status = {
description: "Where AgentHub's Codex hooks stand",
+18
View File
@@ -184,6 +184,24 @@ router.get("/", (req, res) => {
: stmts.countAlertEvents.get().count;
const unacked = stmts.countUnackedAlertEvents.get().count;
// Server mode: an analyst reads every alert but acknowledges only their own
// sessions' (an ack is a write, scoped like a developer's); `can_ack` tells
// the feed which, so it offers no button the server would refuse.
const writer = { user: req.user, method: "POST" };
if (memberId(writer) != null && alerts.length) {
const own = bySessionWhere(writer);
const ids = alerts.map((a) => a.id);
const ackable = new Set(
db
.prepare(
`SELECT id FROM alert_events WHERE id IN (${ids.map(() => "?").join(",")}) AND ${own.where}`
)
.all(...ids, ...own.params)
.map((r) => r.id)
);
for (const a of alerts) a.can_ack = ackable.has(a.id);
}
res.json({ alerts, total, unacked, limit, offset });
});
+84
View File
@@ -0,0 +1,84 @@
/**
* @file me.js
* @description `/api/me` — the signed-in account's own UI preferences
* (`user_prefs`), so they follow it to any browser and differ between the
* accounts of one dashboard. Local mode is one account, `local`; server mode
* keys a row `u<id>` by the login cookie — the request never names an account.
* Only the keys in `PREF_KEYS` are kept, each a string of at most 8 KB, and a
* PUT merges into the row (`null` removes a key). Writes are same-origin.
* Today the one key is `agenthub.chatUi` ("1" = Claude/Codex sessions open as
* a chat, `PaneChat`). Same table, routes and semantics as the server-s3 work,
* which adds its own keys to this list.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { db } = require("../db");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
const router = Router();
/** The keys an account's preferences are made of. */
const PREF_KEYS = ["agenthub.chatUi"];
const MAX_VALUE = 8 * 1024;
/** `u<id>` for a server-mode account, `local` in local mode, null when nobody is signed in. */
function userKey(req) {
if (!auth.isServerMode()) return "local";
const user = req.user || auth.userFromRequest(req);
return user ? `u${user.id}` : null;
}
const getRow = db.prepare("SELECT prefs, onboarded_at FROM user_prefs WHERE user_key = ?");
const upsert = db.prepare(
`INSERT INTO user_prefs (user_key, prefs) VALUES (?, ?)
ON CONFLICT(user_key) DO UPDATE SET prefs = excluded.prefs,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
);
function read(key) {
const row = getRow.get(key);
let prefs = {};
try {
prefs = row ? JSON.parse(row.prefs) : {};
} catch {
/* a damaged row reads as empty */
}
return { prefs, onboardedAt: row?.onboarded_at || null };
}
const unauth = (res) =>
res.status(401).json({ error: { code: "EAUTH", message: "sign in first" } });
router.get("/prefs", (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
res.json(read(key));
});
router.put("/prefs", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
const patch = req.body?.prefs;
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
return res.status(400).json({ error: { code: "EBADPREFS", message: "prefs: an object" } });
}
const { prefs } = read(key);
for (const [k, v] of Object.entries(patch)) {
if (!PREF_KEYS.includes(k)) {
return res.status(400).json({ error: { code: "EBADPREFS", message: `unknown key ${k}` } });
}
if (v === null) delete prefs[k];
else if (typeof v === "string" && v.length <= MAX_VALUE) prefs[k] = v;
else
return res
.status(400)
.json({ error: { code: "EBADPREFS", message: `${k}: a short string` } });
}
upsert.run(key, JSON.stringify(prefs));
res.json(read(key));
});
module.exports = router;
module.exports.PREF_KEYS = PREF_KEYS;
-2
View File
@@ -476,8 +476,6 @@ router.delete("/:id", (req, res) => {
});
module.exports = router;
module.exports.__sameOriginGuard = sameOriginGuard;
module.exports.__sanitiseCwd = sanitiseCwd;
// Shared with routes/lanes.js: lane actions spawn processes through the same
// run-spawner, so they must sit behind the same loopback/same-origin check.
module.exports.sameOriginGuard = sameOriginGuard;
+115 -1
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders) and the push-ingest token. This allows the frontend to manage and maintain the agent monitoring system effectively.
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders), the push-ingest token and the daily database backup (server/lib/db-backup.js). This allows the frontend to manage and maintain the agent monitoring system effectively.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -26,6 +26,7 @@ const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
const { envOwned, applySetting } = require("../lib/settings-env");
const privacy = require("../lib/privacy");
const dbBackup = require("../lib/db-backup");
const pushToken = require("../lib/push-token");
const pricingSync = require("../lib/pricing-sync");
const {
@@ -301,6 +302,41 @@ router.post("/quit", sameOriginGuard, (_req, res) => {
res.json({ ok: true, killed });
});
// POST /api/settings/uninstall {confirm: true} — Uninstall AgentHub
// (scripts/uninstall.js) in a detached worker, because it stops this server.
// Under systemd it starts in a scope of its own, outside the unit's cgroup —
// an older unit still on KillMode=control-group would reap it mid-run; launchd
// leaves it alone (AbandonProcessGroup).
// Never in server mode — a shared dashboard is removed from the host's shell.
router.post("/uninstall", sameOriginGuard, (req, res) => {
if (require("../lib/auth").isServerMode()) {
return res.status(403).json({
error: { code: "ESERVERMODE", message: "Run `agenthub uninstall` on the host instead" },
});
}
if (req.body?.confirm !== true) {
return res
.status(400)
.json({ error: { code: "ECONFIRM", message: "`confirm: true` is required" } });
}
const script = path.join(__dirname, "..", "..", "scripts", "uninstall.js");
const svc = require("../../scripts/service-manager");
const argv = [process.execPath, script, "--yes"];
const [cmd, ...args] =
svc.kind === "systemd" && svc.systemctlAvailable()
? ["systemd-run", "--user", "--scope", "--quiet", "--collect", "--", ...argv]
: argv;
require("child_process")
.spawn(cmd, args, {
cwd: os.tmpdir(), // never the plugin root, which it deletes
detached: true,
stdio: "ignore",
env: process.env,
})
.unref();
res.status(202).json({ ok: true, log: require("../../scripts/uninstall").logPath() });
});
// POST /api/settings/reset-pricing — reset pricing to defaults. The seed is
// Claude's; a model priced by the automatic sync (an OpenAI one, from Codex
// sessions) is fetched back at once, waiting at most RESET_SYNC_MS for it.
@@ -537,6 +573,84 @@ router.put("/privacy", sameOriginGuard, (req, res) => {
res.json({ ok: true, ...privacyState() });
});
// GET/PUT /api/settings/backup, POST /api/settings/backup/run — the daily
// database backup (server/lib/db-backup.js). The folder, hour and count persist
// to the settings file; one the environment set is `env_owned` and refused. A
// run answers 202 at once (a large database takes tens of seconds) and the
// page polls GET while `running`.
function backupState() {
const cfg = dbBackup.backupConfig();
let files = [];
let dir_error = null;
if (cfg.dir) {
try {
files = dbBackup.listBackups(cfg.dir);
} catch (err) {
dir_error = err.message;
}
}
return {
...cfg,
running: dbBackup.isRunning(),
last: dbBackup.lastResult(),
files,
dir_error,
env_owned: Object.fromEntries(
Object.entries(dbBackup.KEYS).map(([field, key]) => [field, envOwned.has(key)])
),
};
}
router.get("/backup", (_req, res) => res.json(backupState()));
router.put("/backup", sameOriginGuard, (req, res) => {
const body = req.body || {};
const fail = (code, message) => res.status(400).json({ error: { code, message } });
const writes = [];
if (body.dir !== undefined) {
if (typeof body.dir !== "string") return fail("INVALID_PATH", "dir must be a string");
let dir = "";
if (body.dir.trim()) {
try {
dir = dbBackup.validateBackupDir(body.dir);
} catch (err) {
return fail(err.code || "INVALID_PATH", err.message);
}
}
writes.push([dbBackup.KEYS.dir, dir]);
}
for (const [field, lo, hi] of [
["hour", 0, 23],
["keep", 1, 365],
]) {
if (body[field] === undefined) continue;
const n = body[field];
if (!Number.isInteger(n) || n < lo || n > hi) {
return fail("INVALID_INPUT", `${field} must be a whole number from ${lo} to ${hi}`);
}
writes.push([dbBackup.KEYS[field], String(n)]);
}
const owned = writes.find(([k]) => envOwned.has(k));
if (owned) {
return res
.status(409)
.json({ error: { code: "EENVOWNED", message: `${owned[0]} is set by the environment` } });
}
try {
for (const [k, v] of writes) applySetting(k, v);
} catch (err) {
return res.status(500).json({ error: { code: "ESAVE", message: err.message } });
}
res.json({ ok: true, ...backupState() });
});
router.post("/backup/run", sameOriginGuard, (_req, res) => {
if (!dbBackup.backupConfig().dir) {
return res
.status(400)
.json({ error: { code: "ENOBACKUPDIR", message: "choose a backup folder first" } });
}
dbBackup.runBackup().catch(() => {}); // the outcome is `last` in GET
res.status(202).json({ ok: true, ...backupState() });
});
// GET/POST/DELETE /api/settings/push-token — the token for POST
// /api/hooks/ingest-batch (server/lib/push-token.js). GET never returns it;
// POST makes a new one (the old one stops working) and returns it this once.
+3 -3
View File
@@ -44,7 +44,7 @@ const USAGE_COLS = [
/** $ and tokens a member's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
function usageSince(userId, sinceHour, rules) {
const { calculateCost } = require("./pricing");
const w = sessionWhere({ user: { id: userId, role: "member" } }, "s.id", "s.lane_id");
const w = sessionWhere({ user: { id: userId, role: "developer" } }, "s.id", "s.lane_id");
const rows = db
.prepare(
`SELECT h.model, h.speed, h.inference_geo, h.service_tier,
@@ -66,7 +66,7 @@ const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slic
router.get("/", (_req, res) => {
const rules = stmts.listPricing.all();
const users = listUsers().map((u) => {
const w = sessionWhere({ user: { id: u.id, role: "member" } });
const w = sessionWhere({ user: { id: u.id, role: "developer" } });
return {
...u,
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
@@ -132,7 +132,7 @@ function target(req) {
router.post(
"/",
accountChange(async (_req, body) => {
const role = body.role === undefined ? "member" : body.role;
const role = body.role === undefined ? "developer" : body.role;
const user = await auth.createUser(body.username, body.password, role);
return { status: 201, user, action: `create ${user.username} (${user.role})` };
})
+7 -6
View File
@@ -169,8 +169,9 @@ function initPtyWebSocket(server) {
socket.destroy();
return;
}
// Server mode: a member attaches only to a pane of their own workspace.
if (isServerMode() && !canSeeRun({ user: userFromRequest(req) }, match[1])) {
// Server mode: a terminal is a write, so a developer or an analyst
// attaches only to a pane of their own workspace.
if (isServerMode() && !canSeeRun({ user: userFromRequest(req), method: "POST" }, match[1])) {
socket.destroy();
return;
}
@@ -192,7 +193,7 @@ function initPtyWebSocket(server) {
});
// Server mode: a terminal stays open only while its user may still use it —
// logged in, not disabled, not an analyst, still owning the pane's workspace.
// logged in, not disabled, still owning the pane's workspace.
const recheck = setInterval(() => {
if (!ptyWss) {
clearInterval(recheck);
@@ -201,7 +202,7 @@ function initPtyWebSocket(server) {
ptyWss.clients.forEach((ws) => {
if (ws.cookie === undefined) return;
const user = userFromRequest({ headers: { cookie: ws.cookie } });
if (!user || user.role === "analyst" || !canSeeRun({ user }, ws.runId)) {
if (!user || !canSeeRun({ user, method: "POST" }, ws.runId)) {
try {
ws.close(1008, "log in again");
} catch {
@@ -222,7 +223,7 @@ function broadcast(type, data) {
// Server mode: worked out once, and only when a member is connected.
let audience;
const reaches = (client) => {
if (!client.user || client.user.role !== "member")
if (!client.user || client.user.role !== "developer")
return !isServerMode() || Boolean(client.user);
if (audience === undefined) {
try {
@@ -237,7 +238,7 @@ function broadcast(type, data) {
wss.clients.forEach((client) => {
if (client.readyState === 1 && reaches(client)) {
try {
const member = client.user && client.user.role === "member";
const member = client.user && client.user.role === "developer";
if (member && audience && audience.strip) {
stripped ??= JSON.stringify({ type, data: {}, timestamp: new Date().toISOString() });
client.send(stripped);