chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- 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
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* @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
|
||||
* 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
|
||||
@@ -161,7 +161,7 @@ describe("files", () => {
|
||||
});
|
||||
|
||||
describe("skills", () => {
|
||||
it("snapshots a workspace skill and installs it into the caller's Claude home", async () => {
|
||||
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" });
|
||||
@@ -169,11 +169,25 @@ describe("skills", () => {
|
||||
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);
|
||||
@@ -183,8 +197,35 @@ describe("skills", () => {
|
||||
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);
|
||||
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 () => {
|
||||
@@ -476,6 +517,46 @@ describe("sharing and visibility", () => {
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user