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
+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");
});