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,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 {
|
||||
|
||||
Reference in New Issue
Block a user