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
+52
View File
@@ -181,6 +181,58 @@ describe("stepsFor", () => {
});
});
describe("a system-wide npm prefix", () => {
const ro = path.join(root, "ro-prefix");
const user = path.join(root, "user-local");
const asRoot = process.getuid?.() === 0;
before(() => {
for (const d of [path.join(ro, "lib", "node_modules"), path.join(ro, "bin")]) {
fs.mkdirSync(d, { recursive: true });
fs.chmodSync(d, 0o555);
}
versions.__setUserPrefix(user);
});
after(() => {
versions.__setUserPrefix(undefined);
for (const d of [path.join(ro, "lib", "node_modules"), path.join(ro, "bin")]) {
fs.chmodSync(d, 0o755);
}
});
it("installs into the account's ~/.local when npm's global prefix is not writable", async (t) => {
if (asRoot) return t.skip("root writes anywhere");
versions.__setExec(async (_f, args) => {
if (args[0] === "prefix") return { stdout: `${ro}\n` };
throw new Error("unexpected");
});
assert.deepEqual(await jobs.stepsFor("fresh", "install"), [
{
argv: [path.join(shellBin, "npm"), "install", "-g", "--prefix", user, "fresh-pkg@latest"],
},
]);
});
it("updates a tool under ~/.local/lib/node_modules with the same --prefix", async () => {
const pkg = path.join(user, "lib", "node_modules", "fresh-pkg", "cli.js");
exe(pkg);
fs.mkdirSync(path.join(user, "bin"), { recursive: true });
fs.symlinkSync(pkg, path.join(serverBin, "fresh"));
aiTools.refresh();
try {
assert.equal(await versions.managedBy("fresh"), "user");
assert.deepEqual(await jobs.stepsFor("fresh", "update"), [
{
argv: [path.join(shellBin, "npm"), "install", "-g", "--prefix", user, "fresh-pkg@latest"],
},
]);
} finally {
fs.rmSync(path.join(serverBin, "fresh"));
aiTools.refresh();
}
});
});
describe("versions", () => {
it("marks npm-managed by realpath and badges a newer version", async () => {
const v = await versions.versions({ fresh: true });
+1 -1
View File
@@ -206,7 +206,7 @@ describe("install / update / icons", () => {
it("maps refusals to statuses", async () => {
jobs.__reset();
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
const noInstall = await request("POST", "/api/ai-tools/antigravity/install");
const noInstall = await request("POST", "/api/ai-tools/claude/install");
assert.equal(noInstall.status, 400);
assert.equal(noInstall.body.error.code, "ENOINSTALL");
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
+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 () => {
+119 -1
View File
@@ -2,7 +2,9 @@
* @file Route tests for the workspace File Explorer (`/api/lanes/:id/fs/*`,
* server/lib/file-explorer.js): list, mkdir/newfile, rename, move, copy (in
* place, across workspaces), delete into the trash and restore/purge, a
* streamed upload into new sub-folders with every conflict policy, a zip of
* streamed upload into new sub-folders with every conflict policy, a batch
* of many small files in one request (and, in a member's folder, as ONE
* write-as `extract` of a tar that real `tar` unpacks), a zip of
* files and folders that unzips back to the same bytes — and the guards: no
* `..`, no symlink walking out, nothing in `.git`, no secret file, the
* workspace folder itself never moved or deleted, cross-origin refused.
@@ -220,6 +222,77 @@ describe("File Explorer", () => {
assert.ok(!fs.existsSync(path.join(W1, "big.bin")));
});
it("uploads many small files as one batch, planned whole before writing", async () => {
const files = [
["many/a.txt", "a"],
["many/deep/b.txt", "bb"],
["many/empty", ""],
[`many/${"n".repeat(120)}/long.txt`, "long"],
];
const body = (list) => {
const head = Buffer.from(
JSON.stringify({ files: list.map(([p, c]) => ({ path: p, size: Buffer.byteLength(c) })) })
);
const len = Buffer.alloc(4);
len.writeUInt32BE(head.length);
return Buffer.concat([len, head, ...list.map(([, c]) => Buffer.from(c))]);
};
const batch = (list, conflict = "", dir = "up") =>
request(
"POST",
`/api/lanes/${lane1}/fs/upload-batch?dir=${dir}${conflict ? `&conflict=${conflict}` : ""}`,
body(list),
{ "Content-Type": "application/octet-stream" }
);
assert.equal((await fsApi(lane1, "mkdir", { path: "up" })).status, 201);
const check = await fsApi(lane1, "upload-check", { dir: "up", paths: files.map(([p]) => p) });
assert.deepEqual(check.body, { taken: [], batch: true });
const r = await batch(files);
assert.equal(r.status, 201);
assert.equal(r.body.saved, 4);
for (const [p, c] of files) assert.equal(fs.readFileSync(path.join(W1, "up", p), "utf8"), c);
// Taken names: one 409 naming them all, and nothing of the batch written.
const again = await batch([
["many/a.txt", "x"],
["many/new.txt", "n"],
["many/deep/b.txt", "y"],
]);
assert.equal(again.status, 409);
assert.deepEqual(again.body.error.names, ["many/a.txt", "many/deep/b.txt"]);
assert.ok(!fs.existsSync(path.join(W1, "up/many/new.txt")));
const taken = await fsApi(lane1, "upload-check", { dir: "up", paths: ["many/a.txt", "z"] });
assert.deepEqual(taken.body.taken, ["many/a.txt"]);
// skip / keep / replace for the whole batch.
const skip = await batch(
[
["many/a.txt", "x"],
["many/new.txt", "n"],
],
"skip"
);
assert.deepEqual([skip.body.saved, skip.body.skipped], [1, 1]);
const keep = await batch(
[
["many/a.txt", "k"],
["many/a (1).txt", "k1"],
],
"keep"
);
assert.deepEqual(keep.body.paths, ["up/many/a (2).txt", "up/many/a (1).txt"]);
await batch([["many/a.txt", "R"]], "replace");
assert.equal(fs.readFileSync(path.join(W1, "up/many/a.txt"), "utf8"), "R");
// The guards are upload's: no .git, no `..`, no file over a folder, no short body.
assert.equal((await batch([[".git/hooks/x", "x"]], "", "")).status, 400);
assert.equal((await batch([["../out", "x"]])).status, 400);
assert.equal((await batch([["many", "x"]])).status, 409);
const short = body([["s.txt", "12345"]]).subarray(0, -2);
const cut = await request("POST", `/api/lanes/${lane1}/fs/upload-batch?dir=up`, short, {
"Content-Type": "application/octet-stream",
});
assert.equal(cut.status, 400);
assert.ok(!fs.existsSync(path.join(W1, "up/s.txt")));
});
it("zips files and folders into one download that unzips to the same bytes", async () => {
const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
assert.equal(r.status, 200);
@@ -286,6 +359,51 @@ describe("File Explorer in a member's folder (S3, run-as stubbed)", () => {
assert.ok(fs.existsSync(path.join(M, "f.txt")), "the host itself moved nothing");
});
it("writes a batch as ONE write-as extract whose tar real tar unpacks", async () => {
const { spawn } = require("node:child_process");
const prev = [runAs.writeAsAsync, runAs.writeAsCanExtract];
const runs = [];
runAs.writeAsCanExtract = () => true;
runAs.writeAsAsync = (_a, file, args, { input }) => {
runs.push([file, ...args]);
return new Promise((resolve, reject) => {
const tar = spawn("tar", ["-x", "-k", "-p", "--no-same-owner", "-f", "-", "-C", file]);
let err = "";
tar.stderr.on("data", (d) => (err += d));
tar.on("close", (code) => (code ? reject(new Error(err)) : resolve()));
input.pipe(tar.stdin);
});
};
try {
const long = `${"x".repeat(150)}/${"y".repeat(90)}.txt`;
const list = [
["b/one.txt", "1"],
["b/sub/two.txt", "22"],
[long, "L"],
];
const head = Buffer.from(
JSON.stringify({ files: list.map(([p, c]) => ({ path: p, size: c.length })) })
);
const len = Buffer.alloc(4);
len.writeUInt32BE(head.length);
const { Readable } = require("node:stream");
const out = await fx.uploadBatch(
M,
"d",
Readable.from([Buffer.concat([len, head, ...list.map(([, c]) => Buffer.from(c))])])
);
assert.equal(out.saved, 3);
assert.deepEqual(runs, [[path.join(M, "d"), "extract"]]);
for (const [p, c] of list) assert.equal(fs.readFileSync(path.join(M, "d", p), "utf8"), c);
assert.equal((fs.statSync(path.join(M, "d/b/one.txt")).mode & 0o777).toString(8), "644");
// An old helper: the check says "no batch", the batch says EUPGRADE.
runAs.writeAsCanExtract = () => false;
assert.equal((await fx.uploadCheck(M, "d", ["z.txt"])).batch, false);
} finally {
[runAs.writeAsAsync, runAs.writeAsCanExtract] = prev;
}
});
it("says the helper needs updating instead of failing obscurely", async () => {
runAs.writeAsCanMove = () => false;
try {
+63
View File
@@ -332,6 +332,69 @@ describe("POST /api/run/drop-image", () => {
});
assert.equal(r.status, 400);
});
it("refuses a file declared over the limit before its body arrives", async () => {
// 4 GB announced, 1 KB sent: the answer must come without the rest.
const { status, body } = await new Promise((resolve, reject) => {
const url = new URL("/api/run/drop-file?name=big.mp4", BASE);
const req = http.request(url, {
method: "POST",
headers: { "Content-Type": "video/mp4", "Content-Length": 4 * 1024 ** 3 },
});
req.on("response", (res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => {
req.destroy();
resolve({ status: res.statusCode, body: JSON.parse(text) });
});
});
req.on("error", reject);
req.write(Buffer.alloc(1024));
});
assert.equal(status, 413);
assert.equal(body.error.code, "ETOOBIG");
assert.equal(body.error.max_bytes, 20 * 1024 * 1024); // the UI shows the server's limit
});
});
describe("POST /api/run/local-paths", () => {
let srv;
before(async () => {
srv = http.createServer(createApp());
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
BASE = `http://127.0.0.1:${srv.address().port}`;
});
after(() => new Promise((r) => srv.close(r)));
it("answers the size of each regular file, null for anything else", async () => {
const file = path.join(TMP, "clip.mp4");
fs.writeFileSync(file, "12345");
const r = await fetchJson("/api/run/local-paths", {
method: "POST",
body: { paths: [file, TMP, path.join(TMP, "missing.bin")] },
});
assert.equal(r.status, 200);
assert.deepEqual(r.body.sizes, [5, null, null]);
});
it("refuses a relative path", async () => {
const r = await fetchJson("/api/run/local-paths", {
method: "POST",
body: { paths: ["clip.mp4"] },
});
assert.equal(r.status, 400);
});
it("refuses a request a proxy forwarded: that browser is elsewhere", async () => {
const r = await fetchJson("/api/run/local-paths", {
method: "POST",
headers: { "X-Forwarded-For": "10.0.0.5" },
body: { paths: [TMP] },
});
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EREMOTE");
});
});
describe("GET /api/run/drop/:name", () => {
+47 -1
View File
@@ -18,7 +18,12 @@ const fs = require("node:fs");
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { classifyTranscriptSender, patchCounts, askAnswers } = require("../routes/sessions");
const {
classifyTranscriptSender,
patchCounts,
askAnswers,
agentInfo,
} = require("../routes/sessions");
const { db } = require("../db");
after(() => {
@@ -187,3 +192,44 @@ describe("askAnswers — what the user picked or typed for an AskUserQuestion",
assert.equal(askAnswers("text"), null);
});
});
describe("agentInfo — what an Agent call's result says about its subagent", () => {
it("reads a foreground agent's id, model, time, tokens and tool calls", () => {
const r = {
status: "completed",
agentId: "a2f215755130779a8",
resolvedModel: "claude-opus-5-5",
totalDurationMs: 132350,
totalTokens: 88007,
totalToolUseCount: 31,
content: [{ type: "text", text: "report" }],
};
assert.deepEqual(agentInfo(r), {
id: "a2f215755130779a8",
model: "claude-opus-5-5",
status: "completed",
duration_ms: 132350,
tokens: 88007,
tool_uses: 31,
});
});
it("leaves unknown facts null for a background launch", () => {
const r = { isAsync: true, status: "async_launched", agentId: "ab12", description: "x" };
assert.deepEqual(agentInfo(r), {
id: "ab12",
model: null,
status: "async_launched",
duration_ms: null,
tokens: null,
tool_uses: null,
});
});
it("answers null for a result that is no agent, or an id that is no plain name", () => {
assert.equal(agentInfo({ stdout: "ok" }), null);
assert.equal(agentInfo({ agentId: "../etc" }), null);
assert.equal(agentInfo("text"), null);
assert.equal(agentInfo(undefined), null);
});
});