0547488f13
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files - Prompt: attach a file on this computer by path (native picker), clearer too-large messages - Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills - Files: batch upload of small files in one extract - AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
636 lines
26 KiB
JavaScript
636 lines
26 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 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, 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 ~/.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-")));
|
|
});
|
|
|
|
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("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"],
|
|
]);
|
|
});
|
|
});
|
|
|
|
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 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("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("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`);
|
|
});
|
|
});
|