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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user