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:
2026-10-20 16:44:55 +07:00
parent 49f87c4da3
commit 0547488f13
59 changed files with 4285 additions and 690 deletions
+84 -3
View File
@@ -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 () => {