feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt

- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
+42
View File
@@ -3186,6 +3186,48 @@ describe("GET /api/sessions/:id/file", () => {
fs.rmSync(dir, { recursive: true, force: true });
assert.equal((await fetch(url(report))).status, 404);
});
it("serves a file the session's Bash or reply named as a whole path (issue #76)", async () => {
const sid = "named-file-" + Date.now();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-named-file-"));
const report = path.join(dir, "REPORT_ssh_ec2.md");
const out = path.join(dir, "out.txt");
const chart = path.join(dir, "chart.png");
const stem = path.join(dir, "REPORT");
const unread = path.join(dir, "locked.md");
fs.writeFileSync(report, "# Audit");
fs.writeFileSync(out, "generated");
fs.writeFileSync(chart, "PNGBYTES");
fs.writeFileSync(stem, "stem");
fs.writeFileSync(unread, "locked");
fs.chmodSync(unread, 0o000);
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
await post("/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: sid,
tool_name: "Bash",
tool_input: { command: `python gen.py -o ${out} && plot ${chart} ${unread}` },
tool_response: { stdout: "done", stderr: "" },
},
});
await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, last_assistant_message: `Báo cáo: \`${report}\`.` },
});
const url = (p) => `/api/sessions/${sid}/file?path=${encodeURIComponent(p)}`;
assert.deepEqual((await fetch(url(report))).body, { text: "# Audit", truncated: false });
assert.deepEqual((await fetch(url(out))).body, { text: "generated", truncated: false });
const img = await fetch(`/api/sessions/${sid}/image?path=${encodeURIComponent(chart)}`);
assert.equal(img.status, 200);
// Only a WHOLE path: `<dir>/REPORT` is a prefix of the named report, not named itself.
assert.equal((await fetch(url(stem))).status, 404);
// Named, but its account could not read it (root sees through the mode).
if (process.getuid?.() !== 0) assert.equal((await fetch(url(unread))).status, 404);
fs.chmodSync(unread, 0o600);
fs.rmSync(dir, { recursive: true, force: true });
});
});
describe("GET /api/sessions/:id/image", () => {
+253
View File
@@ -0,0 +1,253 @@
/**
* @file Tests for the Artifacts hub (server/lib/artifacts.js, routes/artifacts.js):
* claude.ai pages (link allowlist, hook capture, one row per URL), workspace
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
* symlink/secret refusal, install into the caller's Claude home with
* replace moving the old one aside), plugins (marketplace allowlist, install
* argv), sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, and same-origin writes.
* @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 http = require("node:http");
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "artifacts-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp } = require("../index");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const artifacts = require("../lib/artifacts");
const ws = path.join(ROOT, "ws");
let lane;
let server;
let base;
function request(method, p, body, headers = {}) {
return fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({
status: r.status,
headers: r.headers,
text: await r.text(),
})).then((r) => {
let json = null;
try {
json = JSON.parse(r.text);
} catch {
/* not JSON */
}
return { ...r, body: json };
});
}
before(async () => {
fs.mkdirSync(path.join(ws, "out"), { recursive: true });
fs.writeFileSync(path.join(ws, "out", "report.html"), "<h1>hi</h1><script>x()</script>");
fs.writeFileSync(path.join(ws, "main.js"), "code");
const skill = path.join(ws, ".claude", "skills", "deploy");
fs.mkdirSync(path.join(skill, "scripts"), { recursive: true });
fs.writeFileSync(path.join(skill, "SKILL.md"), "---\nname: deploy\ndescription: Ship it\n---\nDo it.\n");
fs.writeFileSync(path.join(skill, "scripts", "run.sh"), "echo hi\n");
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
lane = lanesLib.createLane({ cwd: ws, title: "ws" });
db.prepare("INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)").run(
ws,
lane.id
);
const ev = db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES ('s1', 'PostToolUse', ?, ?)"
);
ev.run("Write", JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } }));
ev.run("Edit", JSON.stringify({ tool_input: { file_path: path.join(ws, "main.js") } }));
ev.run("Write", JSON.stringify({ tool_input: { file_path: "/etc/passwd.md" } }));
server = http.createServer(createApp());
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("pages", () => {
it("accepts only a claude.ai link, and keeps one row per link", async () => {
for (const url of ["http://claude.ai/x", "https://evil.example/claude.ai/", "javascript:x"]) {
const r = await request("POST", "/api/artifacts/page", { url });
assert.equal(r.status, 400, url);
assert.equal(r.body.error.code, "EBADURL");
}
const url = "https://claude.ai/code/artifact/abc-123";
const a = await request("POST", "/api/artifacts/page", { url, title: "One" });
assert.equal(a.status, 201);
const b = await request("POST", "/api/artifacts/page", { url, title: "Two" });
assert.equal(b.body.item.id, a.body.item.id);
assert.equal(b.body.item.title, "Two");
});
it("captures a publish from the Artifact tool's hook, owned by the session's workspace", () => {
const row = artifacts.captureFromHook("s1", {
tool_input: { action: "publish", file_path: "/x/plan.html" },
tool_response: "Published: https://claude.ai/artifact/zz9-Q\nWatching it.",
});
assert.equal(row.url, "https://claude.ai/artifact/zz9-Q");
assert.equal(row.title, "plan.html");
assert.equal(row.lane_id, lane.id);
assert.equal(artifacts.captureFromHook("s1", { tool_input: { action: "read" } }), null);
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
});
});
describe("files", () => {
it("lists only documents a session wrote inside the workspace", async () => {
const r = await request("GET", `/api/artifacts/candidates?laneId=${lane.id}`);
assert.equal(r.status, 200);
assert.deepEqual(
r.body.items.map((i) => i.path),
["out/report.html"]
);
});
it("pins a snapshot and serves it sandboxed, never a path outside it", async () => {
const r = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "out/report.html" });
assert.equal(r.status, 201, r.text);
const id = r.body.item.id;
// The live file changes; the artifact does not.
fs.writeFileSync(path.join(ws, "out", "report.html"), "changed");
const raw = await request("GET", `/api/artifacts/${id}/raw`);
assert.equal(raw.status, 200);
assert.match(raw.text, /<h1>hi<\/h1>/);
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
assert.equal(raw.headers.get("x-content-type-options"), "nosniff");
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
assert.equal(other.status, 404);
const escape = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "../t.db" });
assert.equal(escape.status, 400);
const del = await request("DELETE", `/api/artifacts/${id}`);
assert.equal(del.status, 200);
assert.equal((await request("GET", `/api/artifacts/${id}`)).status, 404);
});
});
describe("skills", () => {
it("snapshots a workspace skill and installs it into the caller's Claude home", async () => {
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
assert.deepEqual(list.body.items, [{ name: "deploy", from: "workspace" }]);
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
assert.equal(r.status, 201, r.text);
assert.equal(r.body.item.description, "Ship it");
assert.deepEqual(
r.body.item.files.map((f) => f.path).sort(),
["SKILL.md", "scripts/run.sh"]
);
const id = r.body.item.id;
const first = await request("POST", `/api/artifacts/${id}/install`, {});
assert.equal(first.status, 200, first.text);
const target = path.join(process.env.CLAUDE_HOME, "skills", "deploy");
assert.equal(fs.readFileSync(path.join(target, "scripts", "run.sh"), "utf8"), "echo hi\n");
const again = await request("POST", `/api/artifacts/${id}/install`, {});
assert.equal(again.status, 409);
assert.equal(again.body.error.code, "EEXISTS");
fs.writeFileSync(path.join(target, "mine.txt"), "keep me");
const replaced = await request("POST", `/api/artifacts/${id}/install`, { replace: true });
assert.equal(replaced.status, 200, replaced.text);
assert.ok(!fs.existsSync(path.join(target, "mine.txt")));
const aside = path.join(process.env.CLAUDE_HOME, "agenthub-replaced-skills");
const [old] = fs.readdirSync(aside);
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
});
it("refuses a skill holding a symlink or a secret-looking file", async () => {
const bad = path.join(ws, ".claude", "skills", "leaky");
fs.mkdirSync(bad, { recursive: true });
fs.writeFileSync(path.join(bad, "SKILL.md"), "x");
fs.symlinkSync("/etc/passwd", path.join(bad, "link"));
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "leaky" });
assert.equal(r.body.error.code, "ESYMLINK");
fs.unlinkSync(path.join(bad, "link"));
fs.writeFileSync(path.join(bad, ".env"), "KEY=1");
const s = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "leaky" });
assert.equal(s.body.error.code, "ESECRET");
});
it("is not installed while an admin has turned installs off", async () => {
const item = (await request("GET", "/api/artifacts?kind=skill")).body.items[0];
const off = await request("PUT", "/api/artifacts/settings", { installsEnabled: false });
assert.equal(off.body.installsEnabled, false);
const r = await request("POST", `/api/artifacts/${item.id}/install`, { replace: true });
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EINSTALLSOFF");
await request("PUT", "/api/artifacts/settings", { installsEnabled: true });
});
});
describe("plugins", () => {
it("takes only name@marketplace from owner/repo or a remote URL, and installs without -y", async () => {
const bad = await request("POST", "/api/artifacts/plugin", { key: "x@y", market: "/etc" });
assert.equal(bad.body.error.code, "EBADMARKET");
const dash = await request("POST", "/api/artifacts/plugin", { key: "-x@y", market: "o/r" });
assert.equal(dash.body.error.code, "EBADPLUGIN");
const r = await request("POST", "/api/artifacts/plugin", { key: "tool@market", market: "o/r" });
assert.equal(r.status, 201, r.text);
const calls = [];
artifacts.__setExecImpl(async (cmd, args) => {
calls.push([cmd, ...args]);
return { stdout: "" };
});
const inst = await request("POST", `/api/artifacts/${r.body.item.id}/install`, {});
assert.equal(inst.status, 200, inst.text);
assert.deepEqual(calls, [
["claude", "plugin", "marketplace", "add", "o/r"],
["claude", "plugin", "install", "--scope", "user", "tool@market"],
]);
});
});
describe("sharing and visibility", () => {
it("refuses to share in local mode", async () => {
const item = (await request("GET", "/api/artifacts")).body.items[0];
const r = await request("PATCH", `/api/artifacts/${item.id}`, { shared: true });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ELOCALMODE");
});
it("shows a server-mode developer their own rows and shared ones only", () => {
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')").run();
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)").run();
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')").run();
const dev = { method: "GET", user: { id: 7, role: "developer", username: "d" } };
const titles = artifacts.list(dev).map((i) => i.title).sort();
assert.deepEqual(titles, ["a", "b"]);
assert.deepEqual(artifacts.list(dev, { scope: "hub" }).map((i) => i.title), ["b"]);
const hidden = db.prepare("SELECT id FROM artifacts WHERE title = 'c'").get().id;
assert.equal(artifacts.canSeeId(dev, hidden), false);
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
assert.throws(() => artifacts.update(dev, shared, { title: "x" }), { code: "EFORBIDDEN" });
});
it("refuses a cross-origin write", async () => {
const r = await request(
"POST",
"/api/artifacts/page",
{ url: "https://claude.ai/artifact/q" },
{ Origin: "https://evil.example" }
);
assert.equal(r.status, 403);
});
});
+30
View File
@@ -150,6 +150,36 @@ describe("dashboard-runs", () => {
assert.deepEqual(offered(), []);
});
it("does not offer a pane that ended by itself while its tmux server ran on", () => {
const { db } = require("../db");
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (21, 'w', '/j')").run();
const session = db.prepare(
"INSERT INTO sessions (id, name, status, cwd) VALUES (?, ?, 'active', '/j')"
);
for (const id of ["q-codex", "q-signal", "q-crash", "q-reboot"]) session.run(id, id);
db.prepare(
"INSERT INTO events (session_id, event_type, data) VALUES ('q-signal', 'SessionEnd', ?)"
).run(JSON.stringify({ reason: "other" }));
start("agenthub-lane-21", { laneId: 21, sessionId: "q-codex", tool: "codex" });
start("agenthub-lane-21-1", { laneId: 21, sessionId: "q-signal" });
start("agenthub-lane-21-2", { laneId: 21, sessionId: "q-crash" });
start("agenthub-lane-21-3", { laneId: 21, sessionId: "q-reboot", tool: "codex" });
// The host's tmux has run since long before; lane 21-3's server is gone.
const since = (id) => (id === "agenthub-lane-21-3" ? null : 0);
runs.reconcileOrphans((id) => !id.startsWith("agenthub-lane-21"), since);
const status = (id) => runs.getRun(id).status;
assert.equal(status("agenthub-lane-21"), "abandoned", "Codex quit by the user");
assert.equal(status("agenthub-lane-21-1"), "abandoned", "closed, tmux ran on");
assert.equal(status("agenthub-lane-21-2"), "interrupted", "Claude never said it ended");
assert.equal(status("agenthub-lane-21-3"), "interrupted", "died with its server");
// A server newer than the run restarted under it: interrupted too.
start("agenthub-lane-21-4", { laneId: 21, sessionId: "q-codex", tool: "codex" });
runs.reconcileOrphans((id) => !id.startsWith("agenthub-lane-21"), () => Date.now());
assert.equal(status("agenthub-lane-21-4"), "interrupted");
});
it("gives a hookless tool's session the lane of the run it started in", () => {
const { db } = require("../db");
start("agenthub-lane-7-1", {
+73 -2
View File
@@ -192,6 +192,54 @@ describe("POST /api/lanes/clone", () => {
});
});
describe("POST /api/lanes/:id/sources/clone", () => {
it("clones beside the workspace folder and adds it as an extra folder", async () => {
const home = path.join(WORK, "app");
fs.mkdirSync(home);
const made = await request("POST", "/api/lanes/ensure", { cwd: home });
assert.ok([200, 201].includes(made.status), JSON.stringify(made.body));
const id = made.body.lane.id;
const r = await request("POST", `/api/lanes/${id}/sources/clone`, {
url: "https://forge.test/o/r.git",
name: "lib",
// Ignored: the clone always lands beside the workspace folder.
parent: path.join(ROOT, "elsewhere"),
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const target = path.join(WORK, "lib");
assert.equal(fs.realpathSync(r.body.path), fs.realpathSync(target));
assert.equal(fs.readFileSync(path.join(target, "README.md"), "utf8"), "hi\n");
const folders = r.body.sources.filter((s) => s.kind === "folder");
assert.deepEqual(
folders.map((f) => fs.realpathSync(f.path)),
[fs.realpathSync(target)]
);
const again = await request("POST", `/api/lanes/${id}/sources/clone`, {
url: "https://forge.test/o/r.git",
name: "lib",
});
assert.equal(again.status, 409);
assert.equal(again.body.error.code, "EEXISTS");
const login = await request("POST", `/api/lanes/${id}/sources/clone`, {
url: "https://private.test/o/r.git",
name: "private-lib",
});
assert.equal(login.status, 409);
assert.equal(login.body.error.code, "ECREDENTIALS");
assert.equal(login.body.error.auth.host, "private.test");
});
it("answers 404 for a workspace that does not exist", async () => {
const r = await request("POST", "/api/lanes/99999/sources/clone", {
url: "https://forge.test/o/r.git",
});
assert.equal(r.status, 404);
});
});
describe("/api/me/git", () => {
it("makes a key once, returns only its public half, and deletes it", async () => {
await request("DELETE", "/api/me/git/ssh-key");
@@ -219,6 +267,29 @@ describe("/api/me/git", () => {
assert.equal(del.body.ssh, null);
});
it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => {
await request("POST", "/api/me/git/ssh-key", {});
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" });
assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
assert.equal(rsa.body.ssh.type, "rsa");
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600);
assert.ok(!fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was replaced");
const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
encoding: "utf8",
});
assert.match(bits, /^4096 .*\(RSA\)/);
assert.equal(
(await request("POST", "/api/me/git/ssh-key", { replace: true, type: "dsa" })).status,
201
);
// An unknown type falls back to the default (the route only passes rsa through).
assert.equal((await request("GET", "/api/me/git")).body.ssh.type, "ed25519");
await request("DELETE", "/api/me/git/ssh-key");
});
it("reads and sets the commit author, refusing a bad one", async () => {
const r0 = await request("GET", "/api/me/git");
assert.deepEqual(r0.body.author, { name: null, email: null });
@@ -282,10 +353,10 @@ describe("git-identity, member account (S3, stubbed)", () => {
}
});
it("keeps the key and the logins in the member's own home", () => {
it("keeps the key and the logins in the member's own home", async () => {
const account = identity.accountForDir(path.join(HOME, "proj"));
assert.equal(account.kind, "member");
identity.generateSshKey(account);
await identity.generateSshKey(account);
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
@@ -0,0 +1,170 @@
/**
* @file Tests for deleting a user (`DELETE /api/users/:id`, auth.deleteUser):
* the admin's own password again, never yourself (ESELF) or the last admin
* (ELASTADMIN); the user's logins end, their workspaces' records, prefs and
* folder go, the audit log keeps the deed — and under S3 the root helper is
* asked to `delete` the Linux account and home FIRST, so its refusal leaves
* every row in place.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-delete-"));
const USERS = path.join(TMP, "users");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
let server;
let BASE;
const ids = {};
const BOSS_PASS = "admin-pass-1";
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
}
const lane = (cwd, owner) =>
Number(
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(cwd, owner)
.lastInsertRowid
);
before(async () => {
process.env.AGENTHUB_MODE = "server";
process.env.AGENTHUB_USERS_ROOT = USERS;
fs.mkdirSync(path.join(USERS, "mia", "proj"), { recursive: true });
fs.writeFileSync(path.join(USERS, "mia", "proj", "a.txt"), "x");
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.miaLane = lane(path.join(USERS, "mia", "proj"), ids.mia);
db.prepare("INSERT INTO user_prefs (user_key, prefs) VALUES (?, '{}')").run(`u${ids.mia}`);
});
after(() => {
delete process.env.AGENTHUB_MODE;
delete process.env.AGENTHUB_USERS_ROOT;
delete process.env.AGENTHUB_SERVER_CONF;
linux.__setRunImpl(null);
linux.__resetConfCache();
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("Delete user (DELETE /api/users/:id)", () => {
it("needs the admin's own password, and never deletes yourself", async () => {
const boss = await login("boss", BOSS_PASS);
const wrong = await call("DELETE", `/api/users/${ids.mia}`, {
cookie: boss,
body: { currentPassword: "nope" },
});
assert.equal(wrong.status, 403);
assert.equal(wrong.body.error.code, "EREAUTH");
const self = await call("DELETE", `/api/users/${ids.boss}`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(self.status, 409);
assert.equal(self.body.error.code, "ESELF");
assert.ok(db.prepare("SELECT 1 FROM users WHERE id = ?").get(ids.mia));
});
it("refuses a developer", async () => {
const mia = await login("mia", "member-pass-1");
const r = await call("DELETE", `/api/users/${ids.boss}`, {
cookie: mia,
body: { currentPassword: "member-pass-1" },
});
assert.equal(r.status, 403);
});
it("deletes the user, their logins, workspaces' records, prefs and folder — audited", async () => {
const miaCookie = await login("mia", "member-pass-1");
const boss = await login("boss", BOSS_PASS);
const r = await call("DELETE", `/api/users/${ids.mia}`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(db.prepare("SELECT 1 FROM users WHERE id = ?").get(ids.mia), undefined);
assert.equal(db.prepare("SELECT 1 FROM lanes WHERE id = ?").get(ids.miaLane), undefined);
assert.equal(
db.prepare("SELECT 1 FROM user_prefs WHERE user_key = ?").get(`u${ids.mia}`),
undefined
);
assert.ok(!fs.existsSync(path.join(USERS, "mia")), "her folder is gone");
// Her old login no longer works.
assert.equal((await call("GET", "/api/me/account", { cookie: miaCookie })).status, 401);
const audit = db
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC")
.get(ids.mia);
assert.match(audit.path, /^delete mia/);
// The same name can be made again, as a new account.
const again = await auth.createUser("mia", "member-pass-3", "developer");
assert.notEqual(again.id, ids.mia);
});
it("never deletes the last admin", () => {
assert.throws(() => auth.deleteUser("boss"), { code: "ELASTADMIN" });
});
});
describe("Delete user under S3", () => {
const CONF = path.join(TMP, "server.conf");
let calls;
before(() => {
fs.writeFileSync(CONF, `HOST_USER=h\nHOST_UID=1000\nUSERS_ROOT=${USERS}\n`);
process.env.AGENTHUB_SERVER_CONF = CONF;
linux.__resetConfCache();
});
it("asks the root helper to delete the account and home first", async () => {
calls = [];
linux.__setRunImpl((argv) => {
calls.push(argv);
return "ok";
});
const u = await auth.createUser("leo", "member-pass-2", "developer");
calls = [];
auth.deleteUser("leo");
assert.deepEqual(calls, [["/opt/agenthub/sbin/agenthub-provision", "delete", "leo"]]);
assert.equal(db.prepare("SELECT 1 FROM users WHERE id = ?").get(u.id), undefined);
});
it("keeps every row when the helper refuses", async () => {
linux.__setRunImpl(() => "ok");
const u = await auth.createUser("zoe", "member-pass-4", "developer");
const zl = lane(path.join(USERS, "zoe", "p"), u.id);
linux.__setRunImpl(() => {
throw Object.assign(new Error("x"), { stderr: "ah-zoe is not an AgentHub member" });
});
assert.throws(() => auth.deleteUser("zoe"), { code: "EPROVISION" });
assert.ok(db.prepare("SELECT 1 FROM users WHERE id = ?").get(u.id));
assert.ok(db.prepare("SELECT 1 FROM lanes WHERE id = ?").get(zl));
});
});