Files
AgentHub/server/__tests__/artifacts.test.js
T
nntrivi2001 c356cf96a1 fix(artifacts): a page published from its session's scratchpad keeps a viewable copy
The Artifact tool writes a page into the session's own Claude Code
scratchpad by default (<tmp>/claude-<uid>/<project>/<session>/scratchpad),
but pageCopy took the file only from the workspace folder, so such a page
got no copy and Preview said "the file ... is gone" while it was still there.

pageCopy now also accepts that ONE scratchpad, named from the publishing
session's id, its cwd and the workspace account's uid; before reading,
each of its four folders must be a real directory of that account, and each
file is realpath-confined, opened O_NOFOLLOW and must be a regular file of
that uid. Supporting files come only from the page's own root. The older-
page backfill (POST /api/artifacts/:id/copy) uses the session's cwd too.
The noCopy message now says the file may also lie outside both.
2026-11-05 11:20:34 +07:00

1369 lines
59 KiB
JavaScript

/**
* @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 and ~/.agents skills with
* replace and uninstall moving the old one aside), plugins (marketplace allowlist, install
* argv; a marketplace link read into its plugin list, the same plugin added
* twice kept as one row, key/market edited), skills uploaded (one Markdown
* file, a folder, a zip, several skills at once — name and frontmatter set,
* junk skipped, nothing stored when one fails) or written in the dialog, a
* file uploaded, sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, same-origin writes, Claude Docs
* docs captured at their birth, documents captured at a session's stop,
* unowned rows claimed, declined and assigned, and a skill that follows its
* source folder (a newer change wins over a Hub edit) with Update offered to
* an installed copy that differs.
* @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";
// Claude Code's scratchpads (`<tmp>/claude-<uid>/…`) go under this test's own folder.
process.env.CLAUDE_CODE_TMPDIR = ROOT;
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);
});
it("records a Claude Docs doc at its birth, never on a later batch", () => {
const tool = "mcp__claude_ai_Claude_Docs__batch";
assert.equal(artifacts.isPageTool(tool), true);
assert.equal(artifacts.isPageTool("mcp__claude_ai_Claude_Docs__update"), false);
assert.equal(artifacts.isPageTool("Artifact"), true);
const row = artifacts.captureFromHook("s1", {
tool_name: tool,
tool_input: { container: { kind: "project", create: { name: "Q3 plan" } }, batch: [] },
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocAbc123" } }),
});
assert.equal(row.url, "https://claude.ai/artifact/DocAbc123");
assert.equal(row.title, "Q3 plan");
const edit = artifacts.captureFromHook("s1", {
tool_name: tool,
tool_input: { container: { kind: "project", id: "x" }, batch: [] },
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocOther9" } }),
});
assert.equal(edit, null);
// The doc's own link wins over one its content cites earlier in the ack.
const cited = artifacts.captureFromHook("s1", {
tool_name: tool,
tool_input: { container: { kind: "project", create: { name: "Notes" } }, batch: [] },
tool_response: [
{
type: "text",
text:
JSON.stringify({
acks: [{ xml: "see https://claude.ai/artifact/CitedOne1" }],
frame: { artifactUrl: "https://claude.ai/artifact/OwnDoc22" },
}) + "\n\nThe doc's viewer is at https://claude.ai/artifact/OwnDoc22 — open it.",
},
],
});
assert.equal(cited.url, "https://claude.ai/artifact/OwnDoc22");
});
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
const uid = db
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
.run().lastInsertRowid;
const hook = (url) => ({
tool_input: { action: "publish" },
tool_response: `Published: ${url}`,
});
const own = artifacts.captureFromHook("loose-1", hook("https://claude.ai/artifact/m1"), {
kind: "member",
name: "ann",
});
assert.equal(own.owner_id, Number(uid));
assert.equal(own.lane_id, null);
// The host account names nobody; a workspace's owner still wins over the peer.
const host = artifacts.captureFromHook("loose-2", hook("https://claude.ai/artifact/m2"), {
kind: "host",
});
assert.equal(host.owner_id, null);
const ws = artifacts.captureFromHook("s1", hook("https://claude.ai/artifact/m3"), {
kind: "member",
name: "ann",
});
const laneOwner = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(lane.id).owner_id;
assert.equal(ws.owner_id, laneOwner);
assert.equal(ws.lane_id, lane.id);
});
});
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");
// Deleting the source on disk takes nothing away: only a delete in AgentHub does.
const live = path.join(ws, "out", "report.html");
fs.rmSync(live);
assert.equal((await request("GET", `/api/artifacts/${id}`)).status, 200);
assert.match((await request("GET", `/api/artifacts/${id}/raw`)).text, /<h1>hi<\/h1>/);
fs.writeFileSync(live, "changed");
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("my library", () => {
it("adds the shared skills of others the caller installed, and only those", () => {
const ins = db.prepare(
"INSERT INTO artifacts (kind, title, owner_id, source, shared) VALUES ('skill', ?, ?, ?, ?)"
);
const theirs = Number(ins.run("theirs", 77, "lib-theirs", 1).lastInsertRowid);
const notInstalled = Number(ins.run("other", 77, "lib-other", 1).lastInsertRowid);
const unshared = Number(ins.run("private", 77, "lib-private", 0).lastInsertRowid);
const own = Number(ins.run("own", 5, "lib-own", 0).lastInsertRowid);
for (const name of ["lib-theirs", "lib-private"])
fs.mkdirSync(path.join(process.env.CLAUDE_HOME, "skills", name), { recursive: true });
const req = { user: { id: 5, role: "admin" } };
const ids = artifacts.list(req, { scope: "mine", kind: "skill" }).map((a) => [a.id, a.mine]);
assert.deepEqual(
ids.filter(([id]) => [theirs, notInstalled, unshared, own].includes(id)).sort(),
[
[theirs, false],
[own, true],
].sort()
);
db.prepare(
`DELETE FROM artifacts WHERE id IN (${[theirs, notInstalled, unshared, own].join(",")})`
).run();
});
});
describe("skills", () => {
it("snapshots a workspace skill and installs it into ~/.claude/skills and ~/.agents/skills", 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 listed = async () =>
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
assert.equal((await listed()).installed, false);
const first = await request("POST", `/api/artifacts/${id}/install`, {});
assert.equal(first.status, 200, first.text);
assert.equal((await listed()).installed, true);
const who = await request("GET", `/api/artifacts/${id}/installs`);
assert.equal(who.status, 200, who.text);
assert.equal(who.body.items.length, 1);
assert.equal(who.body.items[0].mine, true);
assert.deepEqual(who.body.items[0].paths, first.body.paths);
assert.equal(who.body.unrecorded, 0);
const target = path.join(process.env.CLAUDE_HOME, "skills", "deploy");
assert.equal(fs.readFileSync(path.join(target, "scripts", "run.sh"), "utf8"), "echo hi\n");
// The cross-tool folder beside the Claude home (Codex, Gemini, OpenCode…).
const shared = path.join(ROOT, ".agents", "skills", "deploy");
assert.equal(fs.readFileSync(path.join(shared, "scripts", "run.sh"), "utf8"), "echo hi\n");
assert.deepEqual(first.body.paths, [target, shared]);
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).find((n) => !n.includes("-agents-"));
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
assert.ok(fs.readdirSync(aside).some((n) => n.startsWith("deploy-agents-")));
// Uninstall moves both copies aside too, never deletes them.
const gone = await request("POST", `/api/artifacts/${id}/uninstall`, {});
assert.equal(gone.status, 200, gone.text);
assert.deepEqual(gone.body.paths, [target, shared]);
assert.ok(!fs.existsSync(target) && !fs.existsSync(shared));
assert.equal((await listed()).installed, false);
const kept = fs.readdirSync(aside).filter((n) => n.includes("-uninstalled-"));
assert.equal(kept.length, 2);
const none = await request("POST", `/api/artifacts/${id}/uninstall`, {});
assert.equal(none.status, 409);
assert.equal(none.body.error.code, "ENOTINSTALLED");
});
it("edits a skill's name, description and instructions into a new snapshot", async () => {
const item = (await request("GET", "/api/artifacts?kind=skill")).body.items[0];
const before = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot;
const r = await request("PATCH", `/api/artifacts/${item.id}`, {
title: "Ship",
skill: { name: "ship it", description: "Use to ship", content: "# Ship\n\nDo it." },
});
assert.equal(r.status, 200, r.text);
assert.equal(r.body.item.title, "Ship");
assert.equal(r.body.item.source, "ship-it");
assert.equal(r.body.item.description, "Use to ship");
// Other files are kept; the old snapshot is gone.
assert.ok(r.body.item.files.some((f) => f.path === "scripts/run.sh"));
const after = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot;
assert.notEqual(after, before);
const md = await request("GET", `/api/artifacts/${item.id}/raw?path=SKILL.md`);
assert.match(
md.text,
/^---\nname: ship-it\n[\s\S]*description: Use to ship\n---\n\n# Ship\n\nDo it\.$/
);
const empty = await request("PATCH", `/api/artifacts/${item.id}`, {
skill: { name: "x", description: "d", content: " " },
});
assert.equal(empty.status, 400);
});
it("follows its source folder and offers Update to an installed copy", async () => {
const src = path.join(ws, ".claude", "skills", "notes");
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v1\n---\nOne.\n");
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "notes" });
assert.equal(r.status, 201, r.text);
const id = r.body.item.id;
const listed = async () =>
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
const raw = async () => (await request("GET", `/api/artifacts/${id}/raw?path=SKILL.md`)).text;
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
let item = await listed();
assert.equal(item.installed, true);
assert.equal(item.updateAvailable, false);
const installs = item.installs;
// Unchanged folder: nothing taken.
assert.deepEqual(artifacts.syncSkillSources({ force: true }), []);
// The owner changes it: a new snapshot, and the installed copy is behind.
const snapBefore = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(id).snapshot;
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v2\n---\nTwo.\n");
fs.writeFileSync(path.join(src, "extra.md"), "more\n");
const changed = artifacts.syncSkillSources({ force: true });
assert.deepEqual(
changed.map((c) => c.id),
[id]
);
assert.match(await raw(), /Two\./);
assert.ok(!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", snapBefore)));
item = await listed();
assert.equal(item.description, "v2");
assert.ok(item.files.some((f) => f.path === "extra.md"));
assert.equal(item.updateAvailable, true);
// Update: the copy is replaced (old one moved aside), not counted again.
const up = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(up.status, 200, up.text);
const target = path.join(process.env.CLAUDE_HOME, "skills", "notes");
assert.match(fs.readFileSync(path.join(target, "SKILL.md"), "utf8"), /Two\./);
item = await listed();
assert.equal(item.updateAvailable, false);
assert.equal(item.installs, installs);
// A Hub edit is not undone by the unchanged folder; a newer folder change wins.
const edit = await request("PATCH", `/api/artifacts/${id}`, {
skill: { name: "notes", description: "hub", content: "Edited in the Hub." },
});
assert.equal(edit.status, 200, edit.text);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Edited in the Hub\./);
fs.writeFileSync(
path.join(src, "SKILL.md"),
"---\nname: notes\ndescription: v3\n---\nThree.\n"
);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Three\./);
// Update needs an installed copy.
await request("POST", `/api/artifacts/${id}/uninstall`, {});
const none = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(none.body.error.code, "ENOTINSTALLED");
});
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"],
]);
calls.length = 0;
const un = await request("POST", `/api/artifacts/${r.body.item.id}/uninstall`, {});
assert.equal(un.status, 200, un.text);
assert.deepEqual(calls, [
["claude", "plugin", "uninstall", "--scope", "user", "--keep-data", "tool@market"],
]);
});
});
function upload(p, files, fields = {}) {
const form = new FormData();
const paths = [];
for (const [name, content] of files) {
form.append("files", new Blob([content]), name.split("/").pop());
paths.push(name);
}
form.append("paths", JSON.stringify(paths));
for (const [k, v] of Object.entries(fields)) form.append(k, v);
return fetch(`${base}${p}`, { method: "POST", body: form }).then(async (r) => ({
status: r.status,
body: await r.json(),
}));
}
const snapText = (item, file = "SKILL.md") =>
fs.readFileSync(
path.join(
process.env.DASHBOARD_DATA_DIR,
"artifacts",
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot,
file
),
"utf8"
);
describe("plugins from a marketplace link", () => {
const MARKET = JSON.stringify({
name: "sg",
description: "Smartgift skills",
plugins: [
{ name: "sg-dev", source: "./plugins/dev", description: "Dev team" },
{ name: "sg-common", source: "./plugins/common" },
{ name: "-bad", source: "./x" },
],
});
it("normalizes what a user pastes into a marketplace source", () => {
const n = artifacts.normalizeMarket;
assert.equal(n("https://github.com/o/r"), "o/r");
assert.equal(n("https://github.com/o/r.git/"), "o/r");
assert.equal(
n("https://git.example.vn/Org/skills/src/branch/main/"),
"https://git.example.vn/Org/skills"
);
assert.equal(
n("https://git.example.vn/Org/skills.git"),
"https://git.example.vn/Org/skills.git"
);
assert.throws(() => n("file:///etc"), { code: "EBADMARKET" });
assert.throws(() => n("http://x/y.git"), { code: "EBADMARKET" });
});
it("lists a marketplace's plugins and adds a picked one once", async () => {
let asked = null;
artifacts.__setInspectImpl(async (m) => {
asked = m;
return MARKET;
});
const r = await request("POST", "/api/artifacts/plugin/inspect", {
market: "https://git.example.vn/Org/skills.git",
});
artifacts.__setInspectImpl(null);
assert.equal(r.status, 200, r.text);
assert.equal(asked, "https://git.example.vn/Org/skills.git");
assert.equal(r.body.name, "sg");
assert.deepEqual(
r.body.plugins.map((p) => p.key),
["sg-dev@sg", "sg-common@sg"]
);
const add = (title) =>
request("POST", "/api/artifacts/plugin", { key: "sg-dev@sg", market: r.body.market, title });
const a = await add("Dev");
const b = await add("Dev team");
assert.equal(a.status, 201, a.text);
assert.equal(b.body.item.id, a.body.item.id);
assert.equal(b.body.item.title, "Dev team");
const edit = await request("PATCH", `/api/artifacts/${a.body.item.id}`, {
source: "sg-common@sg",
market: "https://github.com/o/r",
});
assert.equal(edit.status, 200, edit.text);
assert.equal(edit.body.item.source, "sg-common@sg");
assert.equal(edit.body.item.market, "o/r");
const bad = await request("PATCH", `/api/artifacts/${a.body.item.id}`, { source: "nope" });
assert.equal(bad.body.error.code, "EBADPLUGIN");
});
it("refuses a marketplace.json with no usable name", async () => {
artifacts.__setInspectImpl(async () => "{}");
const r = await request("POST", "/api/artifacts/plugin/inspect", { market: "o/r" });
artifacts.__setInspectImpl(null);
assert.equal(r.body.error.code, "EBADMARKET");
});
});
describe("skills from files or written", () => {
it("makes one Markdown file the SKILL.md, named by the user, with a frontmatter", async () => {
const r = await upload(
"/api/artifacts/skill/upload",
[["Notes cho đội.md", "# Do this\nStep 1\n"]],
{
name: "Hướng dẫn deploy",
description: "Use when deploying",
}
);
assert.equal(r.status, 201, JSON.stringify(r.body));
const [item] = r.body.items;
assert.equal(item.source, "Huong-dan-deploy");
assert.deepEqual(
item.files.map((f) => f.path),
["SKILL.md"]
);
const text = snapText(item);
assert.match(text, /^---\nname: Huong-dan-deploy\ndescription: Use when deploying\n---\n/);
assert.match(text, /# Do this/);
});
it("refuses a skill with no description anywhere", async () => {
const r = await upload("/api/artifacts/skill/upload", [["x.md", "body"]], { name: "x" });
assert.equal(r.body.error.code, "ENODESCRIPTION");
});
it("takes a picked folder: the top folder is stripped, junk skipped, the name from the frontmatter", async () => {
const r = await upload("/api/artifacts/skill/upload", [
["my-skill/SKILL.md", "---\nname: release-notes\ndescription: Write notes\n---\nGo."],
["my-skill/scripts/run.sh", "echo"],
["my-skill/.DS_Store", "junk"],
]);
assert.equal(r.status, 201, JSON.stringify(r.body));
const [item] = r.body.items;
assert.equal(item.source, "release-notes");
assert.equal(item.description, "Write notes");
assert.deepEqual(item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
});
it("unpacks a zip holding several skills into one artifact each", async () => {
const AdmZip = require("adm-zip");
const zip = new AdmZip();
zip.addFile("pack/alpha/SKILL.md", Buffer.from("---\nname: alpha\ndescription: A\n---\n"));
zip.addFile("pack/alpha/ref/more.md", Buffer.from("x"));
zip.addFile("pack/beta/skill.md", Buffer.from("---\ndescription: B\n---\nb"));
zip.addFile("__MACOSX/pack/._alpha", Buffer.from("junk"));
const r = await upload("/api/artifacts/skill/upload", [["pack.zip", zip.toBuffer()]]);
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.deepEqual(r.body.items.map((i) => i.source).sort(), ["alpha", "beta"]);
const beta = r.body.items.find((i) => i.source === "beta");
assert.match(snapText(beta), /^---\nname: beta\ndescription: B\n---/);
});
it("stores nothing when one skill of an upload is bad, and refuses a secret or a path out", async () => {
const before = db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n;
const r = await upload("/api/artifacts/skill/upload", [
["a/SKILL.md", "---\ndescription: A\n---\n"],
["b/SKILL.md", "no frontmatter"],
]);
assert.equal(r.body.error.code, "ENODESCRIPTION");
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n,
before
);
const secret = await upload("/api/artifacts/skill/upload", [
["s/SKILL.md", "---\ndescription: S\n---\n"],
["s/.env", "K=1"],
]);
assert.equal(secret.body.error.code, "ESECRET");
const word = await upload("/api/artifacts/skill/upload", [
["w/SKILL.md", "---\ndescription: W\n---\n"],
["w/api-token.json", "{}"],
]);
assert.equal(word.body.error.code, "ESECRET");
// The word alone in a doc or a script is skill content, not a key.
const docs = await upload("/api/artifacts/skill/upload", [
["tokens/SKILL.md", "---\ndescription: Design tokens\n---\n"],
["tokens/design-tokens.md", "# tokens"],
["tokens/scripts/count_tokens.py", "print(1)"],
]);
assert.equal(docs.status, 201, JSON.stringify(docs.body));
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], {
description: "d",
});
assert.equal(out.body.error.code, "EBADPATH");
const none = await upload("/api/artifacts/skill/upload", [
["a.png", "x"],
["b.png", "y"],
]);
assert.equal(none.body.error.code, "ENOSKILLMD");
});
it("writes a skill from the dialog", async () => {
const r = await request("POST", "/api/artifacts/skill/new", {
name: "standup",
description: "Use for the daily standup: summarise yesterday",
content: "Summarise.",
});
assert.equal(r.status, 201, r.text);
assert.match(
snapText(r.body.item),
/^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./
);
const bad = await request("POST", "/api/artifacts/skill/new", {
name: "!!!",
description: "d",
content: "c",
});
assert.equal(bad.body.error.code, "EBADNAME");
});
it("updates the uploader's same-named skill instead of adding a twin", async () => {
const md = (v) => `---\nname: sg-again\ndescription: version ${v}\n---\nBody ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(1)]]);
assert.equal(first.status, 201, JSON.stringify(first.body));
const id = first.body.items[0].id;
const again = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
assert.equal(again.status, 201, JSON.stringify(again.body));
assert.equal(again.body.items[0].id, id);
assert.equal(again.body.items[0].replaced, true);
assert.equal(again.body.items[0].changed, true);
assert.equal(again.body.items[0].description, "version 2");
assert.match(snapText(again.body.items[0]), /Body 2\./);
const rows = db
.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill' AND source = 'sg-again'")
.get();
assert.equal(rows.n, 1);
const same = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
assert.equal(same.body.items[0].changed, false);
});
it("dates a skill by its content, and tells each install's copy from the newest", async () => {
const md = (v) => `---\nname: sg-state\ndescription: d\n---\nState ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(1)]]);
const id = first.body.items[0].id;
const at1 = first.body.items[0].versionAt;
assert.ok(at1);
const states = async () =>
(await request("GET", `/api/artifacts/${id}/installs`)).body.items.map((i) => i.state);
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
assert.deepEqual(await states(), ["latest"]);
// A rename is no new version; new content is.
const renamed = await request("PATCH", `/api/artifacts/${id}`, { title: "State" });
assert.equal(renamed.body.item.versionAt, at1);
await new Promise((r) => setTimeout(r, 5));
const next = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(2)]]);
assert.ok(next.body.items[0].versionAt > at1);
assert.deepEqual(await states(), ["outdated"]);
fs.rmSync(path.join(process.env.CLAUDE_HOME, "skills", "sg-state"), { recursive: true });
assert.deepEqual(await states(), ["removed"]);
});
it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => {
const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]);
const id = first.body.items[0].id;
const no = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(no.status, 409);
assert.equal(no.body.error.code, "ENOSOURCE");
// The folder picked again, under any folder name: onto THIS row.
const r = await upload("/api/artifacts/skill/upload", [["renamed/SKILL.md", md(2)]], {
id: String(id),
});
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.items[0].id, id);
assert.equal(r.body.items[0].source, "sg-up");
assert.match(snapText(r.body.items[0]), /^---\nname: sg-up\n[\s\S]*Up 2\./);
const two = await upload(
"/api/artifacts/skill/upload",
[
["p/a/SKILL.md", "---\nname: a\ndescription: d\n---\n"],
["p/b/SKILL.md", "---\nname: b\ndescription: d\n---\n"],
],
{ id: String(id) }
);
assert.equal(two.body.error.code, "EBADUPLOAD");
});
it("Share again takes the owner's folder, else the skill of an installed plugin", async () => {
const home = process.env.CLAUDE_HOME;
// An uploaded skill whose name a user-scope plugin also ships.
const first = await upload("/api/artifacts/skill/upload", [
["sg-plug/SKILL.md", "---\nname: sg-plug\ndescription: d\n---\nOld.\n"],
]);
const id = first.body.items[0].id;
const writePlugin = (ver, text) => {
const at = path.join(home, "plugins", "cache", "m", "p", ver);
fs.mkdirSync(path.join(at, "skills", "sg-plug"), { recursive: true });
fs.writeFileSync(path.join(at, "skills", "sg-plug", "SKILL.md"), text);
return at;
};
const v1 = writePlugin("1.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin one.\n");
const v2 = writePlugin("2.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin two.\n");
fs.writeFileSync(
path.join(home, "plugins", "installed_plugins.json"),
JSON.stringify({
version: 2,
plugins: {
"p@m": [{ scope: "user", installPath: v2, lastUpdated: "2026-10-30T00:00:00Z" }],
"old@m": [{ scope: "user", installPath: v1, lastUpdated: "2026-01-01T00:00:00Z" }],
"proj@m": [
{ scope: "project", installPath: "/etc", lastUpdated: "2027-01-01T00:00:00Z" },
],
},
})
);
const r = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(r.status, 200, r.text);
assert.equal(r.body.changed, true);
assert.match(snapText(r.body.item), /Plugin two\./);
assert.equal((await request("POST", `/api/artifacts/${id}/reshare`)).body.changed, false);
// The owner's own ~/.claude/skills folder outranks a plugin's copy.
const own = path.join(home, "skills", "sg-plug");
fs.mkdirSync(own, { recursive: true });
fs.writeFileSync(
path.join(own, "SKILL.md"),
"---\nname: sg-plug\ndescription: d\n---\nMine.\n"
);
const mine = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(mine.body.changed, true);
assert.match(snapText(mine.body.item), /Mine\./);
// Recorded as the folder's state: the 15 s sync does not take it twice.
assert.deepEqual(
artifacts.syncSkillSources({ force: true }).filter((c) => c.id === id),
[]
);
fs.rmSync(path.join(home, "plugins"), { recursive: true, force: true });
fs.rmSync(own, { recursive: true, force: true });
});
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
title: "Report",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.item.kind, "file");
assert.equal(r.body.item.title, "Report");
const raw = await request("GET", `/api/artifacts/${r.body.item.id}/raw`);
assert.equal(raw.text, "<h1>x</h1>");
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
});
});
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" });
// Deleting a shared row from its owner's library keeps it in the Hub;
// only unsharing it then deletes it.
const mine = db
.prepare(
"INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'd', 7, 'https://claude.ai/d', 1)"
)
.run().lastInsertRowid;
const kept = artifacts.remove(dev, mine);
assert.equal(kept.kept, true);
assert.ok(!artifacts.list(dev, { scope: "mine" }).some((i) => i.title === "d"));
const hubRow = artifacts.list(dev, { scope: "hub" }).find((i) => i.title === "d");
assert.equal(hubRow.inLibrary, false);
process.env.AGENTHUB_MODE = "server";
let gone;
try {
gone = artifacts.update(dev, mine, { shared: false });
} finally {
delete process.env.AGENTHUB_MODE;
}
assert.equal(gone.removed, true);
assert.equal(db.prepare("SELECT id FROM artifacts WHERE id = ?").get(mine), undefined);
// Installs of someone else's shared row: a developer sees only their own,
// its owner sees everyone's but not where another account wrote it.
db.prepare("UPDATE artifacts SET installs = 3 WHERE id = ?").run(shared);
const add = db.prepare(
"INSERT INTO artifact_installs (artifact_id, user_id, paths) VALUES (?, ?, '[\"/h/x\"]')"
);
add.run(shared, 7);
add.run(shared, 9);
const own = artifacts.installsOf(dev, shared);
assert.equal(own.items.length, 1);
assert.deepEqual(own.items[0].paths, ["/h/x"]);
assert.equal(own.unrecorded, 0);
const owner = { method: "GET", user: { id: 8, role: "developer", username: "o" } };
const seen = artifacts.installsOf(owner, shared);
assert.equal(seen.items.length, 2);
assert.ok(seen.items.every((i) => i.paths === undefined));
assert.equal(seen.unrecorded, 1);
});
it("shares with chosen people only: they see and list it, nobody else does", () => {
const addUser = db.prepare(
"INSERT OR IGNORE INTO users (id, username, role, password_hash, disabled) VALUES (?, ?, 'developer', 'x', ?)"
);
addUser.run(701, "owner701", 0);
addUser.run(702, "ann702", 0);
addUser.run(703, "bob703", 0);
addUser.run(704, "gone704", 1);
const as = (id) => ({ method: "GET", user: { id, role: "developer", username: `u${id}` } });
const id = Number(
db
.prepare(
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'for-ann', 701, 'https://claude.ai/ann')"
)
.run().lastInsertRowid
);
process.env.AGENTHUB_MODE = "server";
try {
// Only enabled, existing users; the owner is dropped from the list.
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [704] }), {
code: "EBADFIELD",
});
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [99999] }), {
code: "EBADFIELD",
});
assert.throws(() => artifacts.update(as(701), id, { sharedWith: "702" }), {
code: "EBADFIELD",
});
// Nobody but its owner may choose who sees it.
assert.throws(() => artifacts.update(as(702), id, { sharedWith: [702] }), {
code: "ENOTFOUND",
});
const v = artifacts.update(as(701), id, { sharedWith: [702, 701, 702] });
assert.equal(v.shared, false, "never to everyone");
assert.deepEqual(
v.sharedWith.map((p) => p.id),
[702]
);
assert.equal(artifacts.canSeeId(as(702), id), true);
assert.equal(artifacts.canSeeId(as(703), id), false);
const ann = artifacts.list(as(702), { scope: "withme" });
assert.deepEqual(
ann.map((a) => a.title),
["for-ann"]
);
assert.equal(ann[0].sharedWithMe, true);
assert.deepEqual(ann[0].sharedWith, [], "a recipient does not see who else got it");
assert.ok(!artifacts.list(as(702), { scope: "hub" }).some((a) => a.id === id));
assert.ok(!artifacts.list(as(703)).some((a) => a.id === id));
assert.deepEqual(artifacts.list(as(703), { scope: "withme" }), []);
assert.deepEqual(artifacts.shareRecipients(id), [702]);
// People to pick from: enabled accounts but the caller, names only.
const people = artifacts.people(as(701));
assert.ok(people.some((p) => p.id === 702 && p.name === "ann702"));
assert.ok(!people.some((p) => p.id === 701 || p.id === 704));
assert.deepEqual(Object.keys(people[0]).sort(), ["id", "name", "username"]);
// A recipient disabled since is not listed, so it never blocks an edit.
artifacts.update(as(701), id, { sharedWith: [702, 703] });
db.prepare("UPDATE users SET disabled = 1 WHERE id = 703").run();
const listed = artifacts.view(
db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id),
as(701)
);
assert.deepEqual(
listed.sharedWith.map((p) => p.id),
[702]
);
artifacts.update(as(701), id, { sharedWith: [702] });
db.prepare("UPDATE users SET disabled = 0 WHERE id = 703").run();
// Out of its owner's library it stays with Ann; taking Ann off deletes it.
assert.equal(artifacts.remove(as(701), id).kept, true);
assert.equal(artifacts.canSeeId(as(702), id), true);
const gone = artifacts.update(as(701), id, { sharedWith: [] });
assert.equal(gone.removed, true);
assert.equal(
db.prepare("SELECT 1 FROM artifact_shares WHERE artifact_id = ?").get(id),
undefined
);
const audited = db
.prepare("SELECT path FROM audit_log WHERE path LIKE '%for-ann%' ORDER BY id")
.all()
.map((r) => r.path);
assert.ok(
audited.some((a) => /shared with ann702/.test(a)),
audited.join(" | ")
);
} finally {
delete process.env.AGENTHUB_MODE;
}
});
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);
});
});
describe("search, frontmatter and marketplace limits", () => {
it("finds a title holding `_` or `%` literally, and only that one", () => {
db.prepare(
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'my_skill', 'https://claude.ai/u1')"
).run();
db.prepare(
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'myXskill', 'https://claude.ai/u2')"
).run();
db.prepare(
"INSERT INTO artifacts (kind, title, url) VALUES ('page', '100% done', 'https://claude.ai/u3')"
).run();
assert.deepEqual(
artifacts.list(null, { q: "my_skill" }).map((i) => i.title),
["my_skill"]
);
assert.deepEqual(
artifacts.list(null, { q: "100%" }).map((i) => i.title),
["100% done"]
);
});
it("replaces a multi-line description whole, and quotes one YAML would misread", () => {
const src =
"---\nname: x\ndescription: >\n old line one\n old line two\nallowed-tools: Read\n---\nbody\n";
assert.equal(
artifacts.withFrontmatter(src, "x", "new desc"),
"---\nname: x\ndescription: new desc\nallowed-tools: Read\n---\nbody\n"
);
assert.match(
artifacts.withFrontmatter("body", "x", "- Use for reports"),
/^description: "- Use for reports"$/m
);
assert.match(
artifacts.withFrontmatter("body", "x", "Use for reports"),
/^description: Use for reports$/m
);
});
it("stops reading a marketplace.json link past 1 MB", async () => {
const real = globalThis.fetch;
let pulled = 0;
globalThis.fetch = async () => {
const body = new ReadableStream({
pull(c) {
pulled += 1;
if (pulled > 100) return c.close();
c.enqueue(new Uint8Array(64 * 1024));
},
});
const res = new Response(body);
Object.defineProperty(res, "url", { value: "https://x.example/m.json" });
return res;
};
try {
await assert.rejects(artifacts.inspectMarket("https://x.example/m.json"), {
code: "ETOOBIG",
});
} finally {
globalThis.fetch = real;
}
assert.ok(pulled < 30, `read ${pulled} chunks`);
});
});
describe("outputs captured at a session's stop", () => {
it("snapshots new documents once, refreshes a changed one, ignores the rest", async () => {
const past = new Date(Date.now() - 3600 * 1000);
fs.utimesSync(path.join(ws, "out", "report.html"), past, past); // older than the session
const deck = path.join(ws, "out", "deck.pptx");
fs.writeFileSync(deck, "PK-deck-v1");
fs.writeFileSync(path.join(ws, "notes.md"), "not an output");
const soon = new Date(Date.now() + 1000); // even a future mtime is captured once
fs.utimesSync(deck, soon, soon);
const first = await artifacts.captureOutputs("s1");
assert.deepEqual(
first.map((r) => r.source),
["out/deck.pptx"]
);
assert.equal(first[0].auto, 1);
assert.equal(first[0].lane_id, lane.id);
assert.equal((await artifacts.captureOutputs("s1")).length, 0, "unchanged: not again");
fs.writeFileSync(deck, "PK-deck-v2");
const later = new Date(Date.now() + 60 * 1000);
fs.utimesSync(deck, later, later);
const again = await artifacts.captureOutputs("s1");
assert.equal(again.length, 1);
assert.equal(again[0].id, first[0].id, "same row, new snapshot");
assert.notEqual(again[0].snapshot, first[0].snapshot);
assert.equal(
artifacts.list(null, { kind: "file" }).find((i) => i.id === first[0].id).auto,
true
);
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
});
it("leaves a web app's own pages alone and still takes a standalone HTML report", async () => {
const put = (rel, text = "<p>x</p>") => {
fs.mkdirSync(path.dirname(path.join(ws, rel)), { recursive: true });
fs.writeFileSync(path.join(ws, rel), text);
const soon = new Date(Date.now() + 5 * 60 * 1000);
fs.utimesSync(path.join(ws, rel), soon, soon);
};
put("app/index.html");
put("app/www/page.html");
put("widget/view.html");
put("widget/view.js", "x()");
put("summary-report.html");
put("out/q3.html");
const got = (await artifacts.captureOutputs("s1")).map((r) => r.source).sort();
assert.deepEqual(got, ["out/q3.html", "summary-report.html"]);
});
});
describe("unowned rows", () => {
it("are suggested, claimed, declined and assigned only by the right people", () => {
const addUser = (name, role) =>
Number(
db
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)")
.run(name, role).lastInsertRowid
);
const bobId = addUser("bob-u", "developer");
const amyId = addUser("amy-u", "developer");
const adminId = addUser("root-u", "admin");
const bob = { method: "POST", user: { id: bobId, role: "developer", username: "bob-u" } };
const amy = { method: "POST", user: { id: amyId, role: "developer", username: "amy-u" } };
const admin = { method: "POST", user: { id: adminId, role: "admin", username: "root-u" } };
db.prepare("UPDATE lanes SET owner_id = ? WHERE id = ?").run(bobId, lane.id);
process.env.AGENTHUB_MODE = "server";
try {
// A host session with no workspace, run inside bob's workspace folder.
const row = artifacts.captureFromHook(
"loose-host",
{
tool_input: { action: "publish" },
tool_response: "https://claude.ai/artifact/un1",
cwd: ws,
},
{ kind: "host" }
);
assert.equal(row.owner_id, null);
assert.equal(row.suggested_owner_id, bobId);
assert.ok(
artifacts.list(bob, { scope: "unowned" }).some((i) => i.id === row.id && i.suggestedForMe)
);
assert.ok(
artifacts.list(admin, { scope: "unowned" }).some((i) => i.id === row.id && i.unowned)
);
assert.equal(artifacts.list(amy, { scope: "unowned" }).length, 0);
assert.equal(artifacts.canSeeId(amy, row.id), false);
assert.throws(() => artifacts.claim(amy, row.id), { code: "ENOTFOUND" });
const claimed = artifacts.claim(bob, row.id);
assert.equal(claimed.ownerId, bobId);
assert.throws(() => artifacts.claim(bob, row.id), { code: "EOWNED" });
const other = artifacts.captureFromHook(
"loose-host-2",
{
tool_input: { action: "publish" },
tool_response: "https://claude.ai/artifact/un2",
cwd: ws,
},
{ kind: "host" }
);
assert.deepEqual(artifacts.decline(bob, other.id), { id: other.id, declined: true });
assert.equal(artifacts.canSeeId(bob, other.id), false, "declined: no longer offered");
assert.throws(() => artifacts.assign(bob, other.id, bobId), { code: "ENOTFOUND" });
assert.throws(() => artifacts.assign(admin, other.id, 999999), { code: "EBADFIELD" });
assert.equal(artifacts.assign(admin, other.id, amyId).ownerId, amyId);
} finally {
delete process.env.AGENTHUB_MODE;
}
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
});
});
describe("a page's own copy, viewable without its claude.ai account", () => {
it("copies the HTML file a page was published from, with the files it names", async () => {
const dir = path.join(ws, "pub");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "deck.html"), '<link href="style.css"><h1>v1</h1>');
fs.writeFileSync(path.join(dir, "style.css"), "h1{color:red}");
fs.writeFileSync(path.join(dir, ".env"), "SECRET=1");
const row = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: {
action: "publish",
file_path: "pub/deck.html",
files: { "style.css": "pub/style.css", ".env": "pub/.env", "x.txt": "/etc/hostname" },
},
tool_response: "Published: https://claude.ai/artifact/copy-1",
});
await Promise.all(artifacts.__pageCopies());
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(row.id);
const files = JSON.parse(kept.files);
assert.equal(files[0].path, "deck.html", "the page itself first");
// A secret-looking file and one outside the workspace are never copied.
assert.deepEqual(
files.map((f) => f.path),
["deck.html", "style.css"]
);
const shot = artifacts.snapshotFile(null, row.id, "deck.html");
assert.match(fs.readFileSync(shot.abs, "utf8"), /v1/);
// A republish of the same link takes the new version, same row.
fs.writeFileSync(path.join(dir, "deck.html"), "<h1>v2</h1>");
const again = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: { action: "publish", file_path: path.join(dir, "deck.html") },
tool_response: "https://claude.ai/artifact/copy-1",
});
assert.equal(again.id, row.id);
await Promise.all(artifacts.__pageCopies());
assert.match(fs.readFileSync(artifacts.snapshotFile(null, row.id).abs, "utf8"), /v2/);
assert.ok(
!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", kept.snapshot)),
"the old copy is dropped"
);
});
it("never copies a file outside the workspace or one that is not HTML", async () => {
const outside = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: { action: "publish", file_path: "/etc/hosts.html" },
tool_response: "https://claude.ai/artifact/copy-out",
});
const typed = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: { action: "publish", file_path: "main.js", type_url: "https://claude.ai/t" },
tool_response: "https://claude.ai/artifact/copy-typed",
});
await Promise.all(artifacts.__pageCopies());
for (const r of [outside, typed]) {
assert.equal(
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(r.id).snapshot,
null
);
}
});
// The Artifact tool writes a page into the session's own scratchpad by default.
const scratchpad = (sid, cwd = ws) =>
path.join(
ROOT,
`claude-${process.getuid()}`,
cwd.replace(/[^A-Za-z0-9]/g, "-"),
sid,
"scratchpad"
);
const SID = "5f0c1e2a-6b7d-4c8e-9f10-112233445566";
it("copies a page published from its session's scratchpad", async () => {
db.prepare(
"INSERT OR IGNORE INTO sessions (id, name, status, cwd, lane_id) VALUES (?, 'sp', 'active', ?, ?)"
).run(SID, ws, lane.id);
const dir = scratchpad(SID);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "themes.html"), "<h1>themes</h1>");
fs.writeFileSync(path.join(dir, "app.js"), "1");
const row = artifacts.captureFromHook(SID, {
cwd: ws,
tool_input: {
action: "publish",
file_path: path.join(dir, "themes.html"),
files: { "app.js": path.join(dir, "app.js"), "x.html": path.join(ws, "pub", "deck.html") },
},
tool_response: "https://claude.ai/artifact/scratch-1",
});
await Promise.all(artifacts.__pageCopies());
const kept = db.prepare("SELECT files FROM artifacts WHERE id = ?").get(row.id);
assert.ok(kept.files, "the page has a copy");
// A supporting file comes only from the page's own root, never the workspace.
assert.deepEqual(
JSON.parse(kept.files).map((f) => f.path),
["themes.html", "app.js"]
);
assert.match(fs.readFileSync(artifacts.snapshotFile(null, row.id).abs, "utf8"), /themes/);
});
it("never copies from another session's scratchpad or through a linked one", async () => {
const other = scratchpad("other-session-1");
fs.mkdirSync(other, { recursive: true });
fs.writeFileSync(path.join(other, "x.html"), "<p>not mine</p>");
const foreign = artifacts.captureFromHook(SID, {
cwd: ws,
tool_input: { action: "publish", file_path: path.join(other, "x.html") },
tool_response: "https://claude.ai/artifact/scratch-foreign",
});
// A scratchpad that is a link to somewhere else is refused too.
const sid2 = "linked-session-2";
const real = path.join(ROOT, "elsewhere");
fs.mkdirSync(real, { recursive: true });
fs.writeFileSync(path.join(real, "y.html"), "<p>elsewhere</p>");
fs.mkdirSync(path.dirname(scratchpad(sid2)), { recursive: true });
fs.symlinkSync(real, scratchpad(sid2));
const linked = artifacts.captureFromHook(sid2, {
cwd: ws,
tool_input: { action: "publish", file_path: path.join(scratchpad(sid2), "y.html") },
tool_response: "https://claude.ai/artifact/scratch-linked",
});
await Promise.all(artifacts.__pageCopies());
for (const r of [foreign, linked]) {
assert.equal(
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(r.id).snapshot,
null
);
}
});
it("takes an older scratchpad page's copy on first view", async () => {
fs.writeFileSync(path.join(scratchpad(SID), "older.html"), "<p>older</p>");
const id = Number(
db
.prepare(
"INSERT INTO artifacts (kind, title, lane_id, session_id, source, url) VALUES ('page', 'older', ?, ?, ?, 'https://claude.ai/artifact/older-sp')"
)
.run(lane.id, SID, path.join(scratchpad(SID), "older.html")).lastInsertRowid
);
const r = await request("POST", `/api/artifacts/${id}/copy`, {});
assert.equal(r.status, 200, r.text);
assert.deepEqual(
r.body.item.files.map((f) => f.path),
["older.html"]
);
});
it("takes an older page's copy on first view, once", async () => {
fs.writeFileSync(path.join(ws, "pub", "old.html"), "<p>old</p>");
const id = Number(
db
.prepare(
"INSERT INTO artifacts (kind, title, lane_id, source, url) VALUES ('page', 'old', ?, ?, 'https://claude.ai/artifact/old-1')"
)
.run(lane.id, path.join(ws, "pub", "old.html")).lastInsertRowid
);
const r = await request("POST", `/api/artifacts/${id}/copy`, {});
assert.equal(r.status, 200, r.text);
assert.deepEqual(
r.body.item.files.map((f) => f.path),
["old.html"]
);
});
it("attaches an exported copy of a Doc or Slides to its page, not as a second card", async () => {
const page = artifacts.captureFromHook("s1", {
tool_input: { action: "publish", type_url: "https://claude.ai/types/slides" },
tool_response: "https://claude.ai/artifact/deck-77",
});
fs.mkdirSync(path.join(ws, "artifacts-export"), { recursive: true });
fs.writeFileSync(path.join(ws, "artifacts-export", "deck-77.pdf"), "%PDF-1.4 deck");
fs.writeFileSync(path.join(ws, "artifacts-export", "nobody-1.md"), "# not ours");
const before = db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n;
const done = await artifacts.captureOutputs("s1");
assert.ok(done.some((r) => r.id === page.id));
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(page.id);
assert.deepEqual(
JSON.parse(kept.files).map((f) => f.path),
["deck-77.pdf"]
);
assert.ok(
!done.some((r) => /artifacts-export/.test(r.source || "")),
"an export never becomes a file card"
);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n, before);
// Unchanged: not taken again.
assert.ok(!(await artifacts.captureOutputs("s1")).some((r) => r.id === page.id));
});
it("turns the copy hint off for new panes", async () => {
assert.equal(artifacts.exportsEnabled(), true);
const r = await request("PUT", "/api/artifacts/settings", { exportsEnabled: false });
assert.equal(r.status, 200, r.text);
assert.equal(r.body.exportsEnabled, false);
assert.equal(r.body.installsEnabled, true, "installs untouched");
assert.equal(
(await request("PUT", "/api/artifacts/settings", { exportsEnabled: "no" })).status,
400
);
artifacts.setExportsEnabled(true);
});
});