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