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:
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
],
|
||||
"homepageUrl": "https://antigravity.google/docs/cli/overview",
|
||||
"faviconDomain": "antigravity.google",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://antigravity.google/cli/install.sh"
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.gemini/GEMINI.md"
|
||||
|
||||
@@ -952,6 +952,31 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_artifacts_shared ON artifacts(shared, kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_artifacts_session ON artifacts(session_id);
|
||||
`);
|
||||
// `in_library = 0`: its owner removed it from their library while it was
|
||||
// shared, so it lives on in the Hub only (`remove`); unsharing it then deletes
|
||||
// it. Existing rows are in their owner's library.
|
||||
try {
|
||||
db.prepare("SELECT in_library FROM artifacts LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE artifacts ADD COLUMN in_library INTEGER NOT NULL DEFAULT 1").run();
|
||||
}
|
||||
|
||||
// One row per install of an artifact (server/lib/artifacts.js `install`):
|
||||
// who, when, and the folders it was written to — `artifacts.installs` only
|
||||
// counts. Installs before this table existed stay counted, not listed.
|
||||
// Additive; rows go with their artifact.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS artifact_installs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
artifact_id INTEGER NOT NULL,
|
||||
user_id INTEGER,
|
||||
paths TEXT NOT NULL DEFAULT '[]',
|
||||
replaced INTEGER NOT NULL DEFAULT 0,
|
||||
installed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
FOREIGN KEY (artifact_id) REFERENCES artifacts(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_artifact_installs_artifact ON artifact_installs(artifact_id, id);
|
||||
`);
|
||||
|
||||
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
|
||||
// not read as "every pipeline has never run". Each lane's live `stages` blob
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file The one-at-a-time install/update runner behind Settings → AI Tools.
|
||||
* Every command comes from a catalog file (never a request) and runs as an
|
||||
* argv with stdin closed and the login shell's PATH first: `npm install -g
|
||||
* <pkg>@latest`, a tool's own updater, or a vendor script the server
|
||||
* <pkg>@latest` (with `--prefix ~/.local` when npm's global prefix is not the
|
||||
* account's to write — a system-wide Node), a tool's own updater, or a vendor script the server
|
||||
* downloads (https, same site as the tool's homepage, ≤ 1 MB) and runs with
|
||||
* `bash <file>`. The job lives in memory — a child of the server, so it dies
|
||||
* with it — and it reports its end only AFTER the PATH is re-read and the tool
|
||||
@@ -27,7 +28,8 @@ let jobTimeoutMs = 10 * 60 * 1000;
|
||||
const SCRIPT_MAX = 1024 * 1024;
|
||||
const EACCES_HINT =
|
||||
"\n[agenthub] npm could not write its global directory (EACCES): this Node is installed " +
|
||||
"system-wide. Use a user-level Node (nvm, fnm) or install the tool by hand.\n";
|
||||
"system-wide and ~/.local is not writable either. Use a user-level Node (nvm, fnm) or install " +
|
||||
"the tool by hand.\n";
|
||||
|
||||
const EXIT_GRACE_MS = 500; // after 'exit', how long to wait for the pipes to close
|
||||
const KILL_GRACE_MS = 5000; // SIGTERM → SIGKILL
|
||||
@@ -44,10 +46,11 @@ function makeErr(code, message) {
|
||||
return err;
|
||||
}
|
||||
|
||||
const npmArgv = (pkg) => {
|
||||
/** `prefix`: the `--prefix` args (none for npm's own global prefix). */
|
||||
const npmArgv = (pkg, prefix = []) => {
|
||||
const npm = versions.npmBin();
|
||||
if (!npm) throw makeErr("ENONPM", "npm was not found on the login shell's PATH");
|
||||
return [npm, "install", "-g", `${pkg}@latest`];
|
||||
return [npm, "install", "-g", ...prefix, `${pkg}@latest`];
|
||||
};
|
||||
|
||||
/** The steps one action on one tool would run — `{argv}` or `{script: url}`. */
|
||||
@@ -59,20 +62,22 @@ async function stepsFor(id, action) {
|
||||
if (!tool.install) throw makeErr("ENOINSTALL", `${tool.label} has no install command`);
|
||||
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
|
||||
return tool.install.kind === "npm"
|
||||
? [{ argv: npmArgv(tool.install.package) }]
|
||||
? [{ argv: npmArgv(tool.install.package, await versions.installPrefixArgs()) }]
|
||||
: [{ script: tool.install.url }];
|
||||
}
|
||||
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
|
||||
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
|
||||
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
|
||||
if (tool.update.kind === "script") return [{ script: tool.install.url }];
|
||||
if (!(await versions.npmManaged(id))) {
|
||||
const by = await versions.managedBy(id);
|
||||
if (!by) {
|
||||
throw makeErr(
|
||||
"ENOTNPM",
|
||||
`${tool.label} was installed outside npm; update it the way it was installed`
|
||||
);
|
||||
}
|
||||
return [{ argv: npmArgv(tool.install.package) }];
|
||||
const prefix = by === "user" ? ["--prefix", versions.userPrefix()] : [];
|
||||
return [{ argv: npmArgv(tool.install.package, prefix) }];
|
||||
}
|
||||
|
||||
function append(j, text) {
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
* standalone build, say — is never offered an npm update: it would install a
|
||||
* second copy that the PATH order shadows. npm is the LOGIN SHELL's (its PATH
|
||||
* first), not the pinned Node's beside the server. Cached for a day.
|
||||
* A system-wide Node (`/usr/local`, root's) leaves npm's global prefix
|
||||
* unwritable to the dashboard's account, so there a global install goes to the
|
||||
* account's own `~/.local` (`--prefix`, bin in `~/.local/bin`) instead, and a
|
||||
* tool found under `~/.local/lib/node_modules` counts as npm-managed there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
const aiTools = require("./ai-tools");
|
||||
@@ -27,6 +32,8 @@ const defaultExec = (file, args, opts) =>
|
||||
)
|
||||
);
|
||||
let exec = defaultExec;
|
||||
let userPrefixImpl = () =>
|
||||
process.platform === "win32" ? null : path.join(os.homedir(), ".local");
|
||||
|
||||
/** The PATH a job runs with: the shell's own order first, then the server's. */
|
||||
const jobPath = () => [shellPath(), process.env.PATH].filter(Boolean).join(path.delimiter);
|
||||
@@ -62,17 +69,63 @@ function under(prefix, file) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether tool `id` is installed AND lives under npm's global prefix. */
|
||||
async function npmManaged(id, prefix) {
|
||||
/** The account's own npm prefix (`~/.local`), or null where npm has none (Windows). */
|
||||
const userPrefix = () => userPrefixImpl();
|
||||
|
||||
/** Whether npm can write `dir` — or, when it is not there yet, its nearest existing parent. */
|
||||
function writable(dir) {
|
||||
let d = dir;
|
||||
for (;;) {
|
||||
try {
|
||||
fs.accessSync(d, fs.constants.W_OK);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") return false;
|
||||
}
|
||||
const up = path.dirname(d);
|
||||
if (up === d) return false;
|
||||
d = up;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a global install into `prefix` would succeed: its package and bin folders. */
|
||||
const prefixWritable = (prefix) =>
|
||||
process.platform === "win32"
|
||||
? writable(prefix)
|
||||
: writable(path.join(prefix, "lib", "node_modules")) && writable(path.join(prefix, "bin"));
|
||||
|
||||
/**
|
||||
* The `--prefix` a global install needs: none while npm's own global prefix is
|
||||
* writable, else the account's `~/.local` (a system-wide Node: EACCES otherwise).
|
||||
*/
|
||||
async function installPrefixArgs() {
|
||||
const npm = npmBin();
|
||||
const prefix = npm ? await npmPrefix(npm) : null;
|
||||
const up = userPrefix();
|
||||
if (!prefix || !up || prefixWritable(prefix)) return [];
|
||||
return ["--prefix", up];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which npm prefix holds tool `id`: `"global"` (npm's own), `"user"` (the
|
||||
* account's `~/.local/lib/node_modules`), or null — not installed, or not by npm.
|
||||
*/
|
||||
async function managedBy(id, prefix) {
|
||||
const d = aiTools.detect(id);
|
||||
if (!d.installed) return false;
|
||||
if (!d.installed) return null;
|
||||
if (prefix === undefined) {
|
||||
const npm = npmBin();
|
||||
prefix = npm ? await npmPrefix(npm) : null;
|
||||
}
|
||||
return !!prefix && under(prefix, d.path);
|
||||
if (prefix && under(prefix, d.path)) return "global";
|
||||
const up = userPrefix();
|
||||
if (up && under(path.join(up, "lib", "node_modules"), d.path)) return "user";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether tool `id` is installed AND lives under npm's global or the account's prefix. */
|
||||
const npmManaged = async (id, prefix) => !!(await managedBy(id, prefix));
|
||||
|
||||
async function compute() {
|
||||
const npm = npmBin();
|
||||
const prefix = npm ? await npmPrefix(npm) : null;
|
||||
@@ -114,10 +167,30 @@ function invalidate() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
/** Test seam: the account's npm prefix (a path, or null for none); undefined restores it. */
|
||||
function __setUserPrefix(p) {
|
||||
userPrefixImpl =
|
||||
p === undefined
|
||||
? () => (process.platform === "win32" ? null : path.join(os.homedir(), ".local"))
|
||||
: () => p;
|
||||
}
|
||||
|
||||
/** Test seam: `fn(file, args, opts) → Promise<{stdout}>`, or null for execFile. */
|
||||
function __setExec(fn) {
|
||||
exec = fn || defaultExec;
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { versions, invalidate, npmManaged, npmBin, jobPath, parseVersion, __setExec };
|
||||
module.exports = {
|
||||
versions,
|
||||
invalidate,
|
||||
npmManaged,
|
||||
managedBy,
|
||||
installPrefixArgs,
|
||||
userPrefix,
|
||||
npmBin,
|
||||
jobPath,
|
||||
parseVersion,
|
||||
__setExec,
|
||||
__setUserPrefix,
|
||||
};
|
||||
|
||||
+271
-50
@@ -23,15 +23,21 @@
|
||||
* their own rows plus shared ones, admin and analyst see every row (like
|
||||
* sessions), and only the owner (or an admin) changes or deletes one.
|
||||
* - Installing a shared skill or plugin is the viewer's click, into the
|
||||
* VIEWER's own account only (`installTarget`): a member's home under S3
|
||||
* VIEWER's own accounts only (`installAccounts`): a member's home under S3
|
||||
* (written AS them through `write-as`, `claude` run as them through sudo),
|
||||
* the host's Claude home for an admin or in local mode. An existing skill
|
||||
* plus the host's home for an admin (their host-folder workspaces run as
|
||||
* the host), or the host alone in local mode. A skill goes into BOTH
|
||||
* `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of each, so
|
||||
* every AI tool and every session of the viewer finds it. An existing skill
|
||||
* is never overwritten without `replace`, and the old one is moved aside to
|
||||
* `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is
|
||||
* installed without `-y`, so one whose marketplace declares an install
|
||||
* command fails rather than runs unseen. An admin can turn installs off
|
||||
* for everyone (`setInstallsEnabled`). Share, unshare and install are
|
||||
* written to `audit_log`.
|
||||
* - Editing a skill's name, description or instructions (`editSkill`) writes
|
||||
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
|
||||
* copies already installed stay as they were (Reinstall picks it up).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -133,6 +139,7 @@ function view(row, req, owners = null) {
|
||||
size: row.size,
|
||||
shared: row.shared === 1,
|
||||
sharedAt: row.shared_at,
|
||||
inLibrary: row.in_library !== 0,
|
||||
installs: row.installs,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -142,13 +149,14 @@ function view(row, req, owners = null) {
|
||||
/**
|
||||
* The rows this request may see. `scope`: `mine` (own rows; every row in
|
||||
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
||||
* Each row also carries `installed` (`installedCheck`).
|
||||
*/
|
||||
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
|
||||
const v = visibleWhere(req);
|
||||
const where = [v.where];
|
||||
const params = [...v.params];
|
||||
if (scope === "mine" && req?.user) {
|
||||
where.push("a.owner_id = ?");
|
||||
where.push("a.owner_id = ? AND a.in_library = 1");
|
||||
params.push(req.user.id);
|
||||
} else if (scope === "hub") {
|
||||
where.push("a.shared = 1");
|
||||
@@ -182,7 +190,8 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
`SELECT a.* FROM artifacts a WHERE ${where.join(" AND ")} ORDER BY a.updated_at DESC, a.id DESC LIMIT ${n}`
|
||||
).all(...params);
|
||||
const owners = new Map();
|
||||
return rows.map((r) => view(r, req, owners));
|
||||
const installed = installedCheck(req);
|
||||
return rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
}
|
||||
|
||||
// ── Small helpers ─────────────────────────────────────────────────────────
|
||||
@@ -255,7 +264,7 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||
);
|
||||
if (same) {
|
||||
sql(
|
||||
"UPDATE artifacts SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
"UPDATE artifacts SET title = ?, in_library = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(cleanTitle(title, same.title), same.id);
|
||||
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
|
||||
}
|
||||
@@ -1114,7 +1123,7 @@ function addPlugin(req, { key, market, title, description }) {
|
||||
).get(k, ownerId);
|
||||
if (same) {
|
||||
sql(
|
||||
`UPDATE artifacts SET market = ?, title = ?, description = ?,
|
||||
`UPDATE artifacts SET market = ?, title = ?, description = ?, in_library = 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(
|
||||
src,
|
||||
@@ -1154,8 +1163,14 @@ function audit(req, row, action) {
|
||||
}
|
||||
|
||||
function update(req, id, body = {}) {
|
||||
const row = load(req, id);
|
||||
let row = load(req, id);
|
||||
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
|
||||
if (body.skill && typeof body.skill === "object") {
|
||||
editSkill(req, row, body.skill);
|
||||
row = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
if (!("title" in body) && !("shared" in body) && !("description" in body))
|
||||
return view(row, req);
|
||||
}
|
||||
const sets = [];
|
||||
const params = [];
|
||||
if ("title" in body) {
|
||||
@@ -1187,6 +1202,12 @@ function update(req, id, body = {}) {
|
||||
}
|
||||
}
|
||||
if (!sets.length) return view(row, req);
|
||||
if (shareChange === "unshared" && row.in_library === 0) {
|
||||
// Off the Hub and out of its owner's library: nothing keeps it.
|
||||
deleteRow(row);
|
||||
audit(req, row, "unshared");
|
||||
return { ...view(row, req), shared: false, removed: true };
|
||||
}
|
||||
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
|
||||
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
|
||||
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
@@ -1194,15 +1215,86 @@ function update(req, id, body = {}) {
|
||||
return view(next, req);
|
||||
}
|
||||
|
||||
function deleteRow(row) {
|
||||
sql("DELETE FROM artifact_installs WHERE artifact_id = ?").run(row.id);
|
||||
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from the caller's library. A SHARED row its owner deletes stays in
|
||||
* the Hub (`in_library = 0`, `{kept: true}`) — only unsharing takes it off
|
||||
* there. Anything else (unshared, already Hub-only, or an admin removing
|
||||
* someone else's) is deleted with its snapshot.
|
||||
*/
|
||||
function remove(req, id) {
|
||||
const row = load(req, id);
|
||||
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
|
||||
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
const owner = !req?.user || row.owner_id === req.user.id;
|
||||
if (owner && row.shared === 1 && row.in_library !== 0) {
|
||||
sql(
|
||||
"UPDATE artifacts SET in_library = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(row.id);
|
||||
return { ...row, in_library: 0, kept: true };
|
||||
}
|
||||
deleteRow(row);
|
||||
if (row.shared === 1) audit(req, row, "deleted");
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* New contents for a skill row: `name` (the folder and /command),
|
||||
* `description` (frontmatter — also the card's description) and `content`
|
||||
* (SKILL.md's body, frontmatter excluded). The other frontmatter keys and
|
||||
* every other file are kept. A new snapshot replaces the old one.
|
||||
*/
|
||||
function editSkill(req, row, { name, description, content }) {
|
||||
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill's contents are edited");
|
||||
if (!row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) throw fail("ENOFILE", "no files");
|
||||
const n = validSkillName(name);
|
||||
const body = typeof content === "string" ? content : "";
|
||||
if (!body.trim()) throw fail("EBADSKILL", "write the skill's instructions");
|
||||
const recorded = row.files ? JSON.parse(row.files) : [];
|
||||
const files = recorded.map((f) => ({
|
||||
path: f.path,
|
||||
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
|
||||
}));
|
||||
const md = files.find((f) => f.path === "SKILL.md");
|
||||
const old = md ? md.data.toString("utf8").replace(/^\uFEFF/, "") : "";
|
||||
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(old);
|
||||
const text = withFrontmatter(
|
||||
`${fm ? fm[0] : ""}${fm ? "\n" : ""}${body.replace(/^\s+/, "")}`,
|
||||
n,
|
||||
description
|
||||
);
|
||||
const all = [
|
||||
{ path: "SKILL.md", data: Buffer.from(text, "utf8") },
|
||||
...files.filter((f) => f !== md),
|
||||
];
|
||||
checkSkillFiles(all);
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const next = [];
|
||||
try {
|
||||
for (const f of all) {
|
||||
const target = path.join(snap.dir, ...f.path.split("/"));
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, f.data, { mode: 0o600 });
|
||||
total += f.data.length;
|
||||
next.push({ path: f.path, size: f.data.length });
|
||||
}
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
throw err;
|
||||
}
|
||||
const desc = frontmatterField(text, "description");
|
||||
sql(
|
||||
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
}
|
||||
|
||||
/** The absolute path of one RECORDED file of a row's snapshot (`rel` must match). */
|
||||
function snapshotFile(req, id, rel) {
|
||||
const row = load(req, id);
|
||||
@@ -1237,55 +1329,140 @@ function installable(req, row) {
|
||||
if (!installsEnabled()) throw fail("EINSTALLSOFF", "an admin turned Hub installs off");
|
||||
}
|
||||
|
||||
/** Install a skill snapshot into the viewer's own `~/.claude/skills/<name>`. */
|
||||
async function installSkill(req, row, { replace = false } = {}) {
|
||||
const { acct, claudeHome } = ownAccount(req);
|
||||
const runAs = require("./run-as");
|
||||
const skills = path.join(claudeHome, "skills");
|
||||
const target = path.join(skills, row.source);
|
||||
const taken = (() => {
|
||||
try {
|
||||
fs.lstatSync(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
if (taken && !replace) throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
|
||||
if (taken) {
|
||||
const aside = path.join(
|
||||
claudeHome,
|
||||
"agenthub-replaced-skills",
|
||||
`${row.source}-${new Date().toISOString().replace(/[:.]/g, "-")}`
|
||||
);
|
||||
if (acct) {
|
||||
if (!runAs.writeAsCanMove()) {
|
||||
throw fail(
|
||||
"EUPGRADE",
|
||||
"replacing needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
/**
|
||||
* Every account a request's sessions run as: `[{acct, home, claudeHome}]`.
|
||||
* Under S3 a pane runs as its FOLDER's account, so an admin with a member
|
||||
* account of their own also runs host-folder workspaces as the host — both
|
||||
* get the skill, or half their sessions never see it.
|
||||
*/
|
||||
function installAccounts(req) {
|
||||
const hostHome = path.dirname(getClaudeHome());
|
||||
const host = { acct: null, home: hostHome, claudeHome: getClaudeHome() };
|
||||
if (!req?.user) return [host];
|
||||
const own = ownAccount(req);
|
||||
const out = [];
|
||||
if (own.acct) out.push({ acct: own.acct, home: own.acct.home, claudeHome: own.claudeHome });
|
||||
if (req.user.role === "admin") out.push(host);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an account's AI tools look for user skills: Claude Code's
|
||||
* `~/.claude/skills` (also read by OpenCode, Cursor, omp, Grok) and the
|
||||
* cross-tool `~/.agents/skills` (Codex, Gemini/Antigravity, OpenCode, Cursor).
|
||||
*/
|
||||
const skillDirsOf = (a) => [
|
||||
path.join(a.claudeHome, "skills"),
|
||||
path.join(a.home, ".agents", "skills"),
|
||||
];
|
||||
|
||||
const exists = (p) => {
|
||||
try {
|
||||
fs.lstatSync(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `row => boolean`: is this skill/plugin already in every account the
|
||||
* viewer's sessions run as (`installAccounts`) — a skill as
|
||||
* `~/.claude/skills/<name>`, a plugin as a key of `installed_plugins.json`.
|
||||
* The list shows "Installed" instead of Install for those. Pure reads; an
|
||||
* account that cannot be resolved or read counts as not installed.
|
||||
*/
|
||||
function installedCheck(req) {
|
||||
let accounts = null;
|
||||
const plugins = new Map();
|
||||
const pluginKeys = (a) => {
|
||||
if (!plugins.has(a.claudeHome)) {
|
||||
const file = path.join(a.claudeHome, "plugins", "installed_plugins.json");
|
||||
let keys = new Set();
|
||||
try {
|
||||
const text = a.acct
|
||||
? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 })
|
||||
: fs.readFileSync(file, "utf8");
|
||||
keys = new Set(Object.keys(JSON.parse(text)?.plugins || {}));
|
||||
} catch {
|
||||
/* none installed, or unreadable */
|
||||
}
|
||||
runAs.mkdirAs(acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(acct, target, ["move", aside]);
|
||||
plugins.set(a.claudeHome, keys);
|
||||
}
|
||||
return plugins.get(a.claudeHome);
|
||||
};
|
||||
return (row) => {
|
||||
if ((row.kind !== "skill" && row.kind !== "plugin") || !row.source) return false;
|
||||
if (accounts === null) {
|
||||
try {
|
||||
accounts = installAccounts(req);
|
||||
} catch {
|
||||
accounts = [];
|
||||
}
|
||||
}
|
||||
if (!accounts.length) return false;
|
||||
return accounts.every((a) =>
|
||||
row.kind === "skill"
|
||||
? exists(path.join(a.claudeHome, "skills", row.source))
|
||||
: pluginKeys(a).has(row.source)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill snapshot as `<name>` into every skills folder of every
|
||||
* account the viewer's sessions run as (`installAccounts` × `skillDirsOf`).
|
||||
*/
|
||||
async function installSkill(req, row, { replace = false } = {}) {
|
||||
const runAs = require("./run-as");
|
||||
const targets = installAccounts(req).flatMap((a) =>
|
||||
skillDirsOf(a).map((dir) => ({ ...a, target: path.join(dir, row.source) }))
|
||||
);
|
||||
const taken = targets.filter((t) => exists(t.target));
|
||||
if (taken.length && !replace) {
|
||||
throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
|
||||
}
|
||||
if (taken.some((t) => t.acct) && !runAs.writeAsCanMove()) {
|
||||
throw fail(
|
||||
"EUPGRADE",
|
||||
"replacing needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
for (const t of taken) {
|
||||
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
|
||||
const aside = path.join(
|
||||
t.claudeHome,
|
||||
"agenthub-replaced-skills",
|
||||
`${row.source}${tag}-${stamp}`
|
||||
);
|
||||
if (t.acct) {
|
||||
runAs.mkdirAs(t.acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
||||
fs.renameSync(target, aside);
|
||||
fs.renameSync(t.target, aside);
|
||||
}
|
||||
}
|
||||
const files = row.files ? JSON.parse(row.files) : [];
|
||||
for (const f of files) {
|
||||
const from = path.join(root(), row.snapshot, ...f.path.split("/"));
|
||||
const to = path.join(target, ...f.path.split("/"));
|
||||
const content = fs.readFileSync(from);
|
||||
if (acct) {
|
||||
runAs.mkdirAs(acct, path.dirname(to));
|
||||
runAs.writeFileAs(to, content);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.writeFileSync(to, content);
|
||||
const contents = files.map((f) => ({
|
||||
rel: f.path.split("/"),
|
||||
content: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
|
||||
}));
|
||||
for (const t of targets) {
|
||||
for (const f of contents) {
|
||||
const to = path.join(t.target, ...f.rel);
|
||||
if (t.acct) {
|
||||
runAs.mkdirAs(t.acct, path.dirname(to));
|
||||
runAs.writeFileAs(to, f.content);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.writeFileSync(to, f.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { installed: target, replaced: taken };
|
||||
const paths = targets.map((t) => t.target);
|
||||
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
|
||||
}
|
||||
|
||||
let execImpl = promisify(require("node:child_process").execFile);
|
||||
@@ -1343,12 +1520,56 @@ async function install(req, id, opts = {}) {
|
||||
const result =
|
||||
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
|
||||
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
|
||||
sql(
|
||||
"INSERT INTO artifact_installs (artifact_id, user_id, paths, replaced) VALUES (?, ?, ?, ?)"
|
||||
).run(row.id, req?.user?.id ?? null, JSON.stringify(result.paths || []), result.replaced ? 1 : 0);
|
||||
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "installed");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who installed artifact `id`, newest first: `{items: [{user, mine, at,
|
||||
* replaced, paths?}], unrecorded}`. The owner, staff and local mode see every
|
||||
* install; anyone else only their own. `paths` (where a skill was written; a
|
||||
* plugin has none — it is `claude plugin install --scope user`) goes only to
|
||||
* the installer and an admin. `unrecorded` = installs counted before this
|
||||
* list was kept.
|
||||
*/
|
||||
function installsOf(req, id) {
|
||||
const row = load(req, id);
|
||||
const u = req?.user;
|
||||
const all = !u || u.role === "admin" || u.role === "analyst" || row.owner_id === u.id;
|
||||
const rows = sql(
|
||||
`SELECT i.*, us.username, us.display_name FROM artifact_installs i
|
||||
LEFT JOIN users us ON us.id = i.user_id
|
||||
WHERE i.artifact_id = ? ${all ? "" : "AND i.user_id = ?"}
|
||||
ORDER BY i.id DESC LIMIT 500`
|
||||
).all(...(all ? [row.id] : [row.id, u.id]));
|
||||
const items = rows.map((r) => {
|
||||
const mine = !u || r.user_id === u.id;
|
||||
let paths = [];
|
||||
try {
|
||||
paths = JSON.parse(r.paths) || [];
|
||||
} catch {
|
||||
paths = [];
|
||||
}
|
||||
return {
|
||||
user: r.user_id == null ? null : r.display_name || r.username || null,
|
||||
mine,
|
||||
at: r.installed_at,
|
||||
replaced: r.replaced === 1,
|
||||
...(mine || u?.role === "admin" ? { paths } : {}),
|
||||
};
|
||||
});
|
||||
const total = sql("SELECT COUNT(*) AS n FROM artifact_installs WHERE artifact_id = ?").get(
|
||||
row.id
|
||||
).n;
|
||||
return { items, unrecorded: all ? Math.max(0, (row.installs || 0) - total) : 0 };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KINDS,
|
||||
installsOf,
|
||||
DOC_EXT,
|
||||
list,
|
||||
load,
|
||||
|
||||
+351
-2
@@ -4,7 +4,8 @@
|
||||
* or into another of the same account), delete into a per-account TRASH that
|
||||
* can be restored (kept TRASH_DAYS) — and, for `/api/homes`, one recycle bin
|
||||
* per USER over every store their deletes went to — upload a file streamed from the request
|
||||
* into any (new) sub-folder, and download files or folders as one streamed zip.
|
||||
* into any (new) sub-folder (or many small ones as ONE batch request), and
|
||||
* download files or folders as one streamed zip.
|
||||
*
|
||||
* Every path is the read path's own (git-diff.js): `lanePath` (lexical, no
|
||||
* `..`), the PARENT resolved by `resolveInLane` (realpath-confined, so no
|
||||
@@ -37,7 +38,8 @@ const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const zlib = require("node:zlib");
|
||||
const { Readable, Transform } = require("node:stream");
|
||||
const { PassThrough, Readable, Transform } = require("node:stream");
|
||||
const { once } = require("node:events");
|
||||
const { AsyncLocalStorage } = require("node:async_hooks");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
|
||||
@@ -706,6 +708,351 @@ async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
|
||||
return { path: loc.rel };
|
||||
}
|
||||
|
||||
// ── Batch upload ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Many small files cost per FILE, not per byte: one request, one path walk
|
||||
// and — in a member's folder — one sudo write-as each. A batch carries them
|
||||
// in ONE request: a 4-byte big-endian length, that many bytes of JSON
|
||||
// `{files: [{path, size, mtime?}]}` (paths relative to `?dir=`), then each
|
||||
// file's bytes in that order. Every name is checked like `upload`'s and the
|
||||
// whole batch is planned (folders, conflicts) before the first write; a
|
||||
// member's batch is written by ONE `write-as <dir> extract` from a tar this
|
||||
// module writes itself (only folders and regular files, names it checked).
|
||||
|
||||
const BATCH_MAX_FILES = 5000;
|
||||
/** A file in a batch is held in memory whole; bigger ones go through `upload`. */
|
||||
const BATCH_FILE_MAX = 16 * 1024 * 1024;
|
||||
const BATCH_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const BATCH_HEAD_MAX = 4 * 1024 * 1024;
|
||||
|
||||
/** Whether a member's batch can be written (write-as `extract`, install-system re-run). */
|
||||
function needExtract(acct) {
|
||||
if (acct && !runAs.writeAsCanExtract()) {
|
||||
throw bad(
|
||||
"EUPGRADE",
|
||||
"uploading many files at once into a member's folder needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads exact byte counts from a stream (the request body). */
|
||||
function byteReader(stream) {
|
||||
const it = stream[Symbol.asyncIterator]();
|
||||
let chunks = [];
|
||||
let have = 0;
|
||||
return {
|
||||
async read(n) {
|
||||
while (have < n) {
|
||||
const { value, done } = await it.next();
|
||||
if (done) throw bad("EBADBODY", "the upload ended early");
|
||||
const b = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
chunks.push(b);
|
||||
have += b.length;
|
||||
}
|
||||
const all = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, have);
|
||||
const rest = all.subarray(n);
|
||||
chunks = rest.length ? [rest] : [];
|
||||
have = rest.length;
|
||||
return all.subarray(0, n);
|
||||
},
|
||||
/** Consume whatever is left, so an answer can still be sent. */
|
||||
async drain() {
|
||||
chunks = [];
|
||||
have = 0;
|
||||
for (;;) if ((await it.next()).done) return;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The `files` list of a batch, each `{path, size, mtime}` checked for shape. */
|
||||
function batchFiles(list) {
|
||||
if (!Array.isArray(list) || !list.length) throw bad("EBADBODY", "a batch names its files");
|
||||
if (list.length > BATCH_MAX_FILES) {
|
||||
throw bad("ETOOBIG", `a batch holds at most ${BATCH_MAX_FILES} files`);
|
||||
}
|
||||
let total = 0;
|
||||
return list.map((f) => {
|
||||
const size = f?.size ?? 0;
|
||||
if (typeof f?.path !== "string" || !Number.isSafeInteger(size) || size < 0) {
|
||||
throw bad("EBADBODY", "each file is {path, size}");
|
||||
}
|
||||
if (size > BATCH_FILE_MAX) {
|
||||
throw bad("ETOOBIG", `a file in a batch may be at most ${BATCH_FILE_MAX / 1024 / 1024} MB`);
|
||||
}
|
||||
total += size;
|
||||
if (total > BATCH_MAX_BYTES) {
|
||||
throw bad("ETOOBIG", `a batch may be at most ${BATCH_MAX_BYTES / 1024 / 1024} MB`);
|
||||
}
|
||||
const mtime = Number.isFinite(f.mtime) && f.mtime > 0 ? f.mtime : Date.now();
|
||||
return { path: f.path, size, mtime };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each file of a batch goes, under `conflict`: the folders to make
|
||||
* (parents first) and per file its target, or `skip`/`replace`. `taken`
|
||||
* lists the names already there (paths relative to the batch's folder);
|
||||
* under `fail` they are not resolved — the caller refuses or reports them.
|
||||
*/
|
||||
async function planBatch(dir, dirRel, files, conflict) {
|
||||
const base = await folder(dir, dirRel || "");
|
||||
if (gitInside(base.rel)) throw bad("EBADPATH", "nothing is uploaded into .git");
|
||||
const full = (r) => (base.rel ? `${base.rel}/${r}` : r);
|
||||
// rel under base → {rel (workspace), abs, missing}
|
||||
const dirs = new Map([["", { rel: base.rel, abs: base.abs, missing: false }]]);
|
||||
const make = [];
|
||||
const dirOf = async (r) => {
|
||||
if (dirs.has(r)) return dirs.get(r);
|
||||
const upRel = path.posix.dirname(r) === "." ? "" : path.posix.dirname(r);
|
||||
const up = await dirOf(upRel);
|
||||
const name = path.posix.basename(r);
|
||||
const d = { rel: full(r), abs: path.join(up.abs, name), missing: true };
|
||||
if (up.missing) {
|
||||
if (topDot(base.root, d.abs)) throw hidden();
|
||||
if (await isSecretOnDisk(dir, d.rel)) throw bad("ESECRET", "that path holds secrets");
|
||||
} else {
|
||||
const loc = await locate(dir, d.rel);
|
||||
if (loc.st && !loc.st.isDirectory()) {
|
||||
throw bad("EEXISTS", `${d.rel} is a file, not a folder`, { names: [r] });
|
||||
}
|
||||
d.missing = !loc.st;
|
||||
}
|
||||
if (d.missing) make.push(d);
|
||||
dirs.set(r, d);
|
||||
return d;
|
||||
};
|
||||
|
||||
const seen = new Set();
|
||||
// A free name for `keep` is free on disk AND not another file of this batch.
|
||||
const claimed = new Set();
|
||||
const own = new Set(
|
||||
files.map((f) => {
|
||||
try {
|
||||
return lanePath(f.path);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
);
|
||||
const out = [];
|
||||
const taken = [];
|
||||
for (const f of files) {
|
||||
const r = lanePath(f.path);
|
||||
for (const seg of r.split("/")) validName(seg);
|
||||
if (gitInside(full(r))) throw bad("EBADPATH", "nothing is uploaded into .git");
|
||||
if (seen.has(r)) throw bad("EBADPATH", `${r} is named twice`);
|
||||
seen.add(r);
|
||||
const upRel = path.posix.dirname(r) === "." ? "" : path.posix.dirname(r);
|
||||
const up = await dirOf(upRel);
|
||||
let name = path.posix.basename(r);
|
||||
let st = null;
|
||||
if (up.missing) {
|
||||
if (await isSecretOnDisk(dir, full(r))) throw bad("ESECRET", "that path holds secrets");
|
||||
} else {
|
||||
const loc = await locate(dir, full(r));
|
||||
st = loc.st;
|
||||
}
|
||||
const step = { ...f, rel: r, name, up, skip: false, replace: false };
|
||||
if (st) {
|
||||
if (st.isDirectory()) throw bad("EEXISTS", `${full(r)} is a folder`, { names: [r] });
|
||||
taken.push(r);
|
||||
if (conflict === "skip") step.skip = true;
|
||||
else if (conflict === "replace") step.replace = true;
|
||||
else if (conflict === "keep") {
|
||||
const ext = name.startsWith(".") ? "" : path.extname(name);
|
||||
const stem = ext ? name.slice(0, -ext.length) : name;
|
||||
for (let i = 1; ; i++) {
|
||||
if (i >= 10_000) throw bad("EEXISTS", `no free name for ${name}`);
|
||||
const n = `${stem} (${i})${ext}`;
|
||||
const abs = path.join(up.abs, n);
|
||||
const nRel = upRel ? `${upRel}/${n}` : n;
|
||||
if (claimed.has(abs) || own.has(nRel)) continue;
|
||||
if (await fs.promises.lstat(abs).catch(() => null)) continue;
|
||||
name = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
step.name = name;
|
||||
step.abs = path.join(up.abs, name);
|
||||
step.full = up.rel ? `${up.rel}/${name}` : name;
|
||||
claimed.add(step.abs);
|
||||
out.push(step);
|
||||
}
|
||||
for (const r of seen) {
|
||||
if (dirs.has(r)) throw bad("EBADPATH", `${r} is both a file and a folder`);
|
||||
}
|
||||
return { base, make, files: out, taken };
|
||||
}
|
||||
|
||||
/**
|
||||
* `{taken, batch}` for a batch about to be sent: the names already in
|
||||
* `dirRel` (relative to it), and whether a batch can be written there at all
|
||||
* (false: send the files one by one).
|
||||
*/
|
||||
async function uploadCheck(dir, dirRel, paths) {
|
||||
if (!Array.isArray(paths) || !paths.every((p) => typeof p === "string")) {
|
||||
throw bad("EBADBODY", "paths is a list of relative paths");
|
||||
}
|
||||
const files = batchFiles(paths.map((p) => ({ path: p, size: 0 })));
|
||||
const plan = await planBatch(dir, dirRel, files, "fail");
|
||||
let batch = true;
|
||||
try {
|
||||
needExtract(accountAt(plan.base.abs));
|
||||
} catch {
|
||||
batch = false;
|
||||
}
|
||||
return { taken: plan.taken, batch };
|
||||
}
|
||||
|
||||
/** One octal tar field of `len` bytes. */
|
||||
function tarOctal(buf, off, len, n) {
|
||||
buf.write(`${n.toString(8).padStart(len - 1, "0")}\0`, off, len, "ascii");
|
||||
}
|
||||
|
||||
/** A ustar header block for `name` (a pax `path` record first when it is long). */
|
||||
function tarHeader(name, { type, size, mode, mtime }) {
|
||||
const block = (n, t, sz) => {
|
||||
const h = Buffer.alloc(512);
|
||||
h.write(n, 0, 100, "utf8");
|
||||
tarOctal(h, 100, 8, mode);
|
||||
tarOctal(h, 108, 8, 0);
|
||||
tarOctal(h, 116, 8, 0);
|
||||
tarOctal(h, 124, 12, sz);
|
||||
tarOctal(h, 136, 12, Math.floor(mtime / 1000));
|
||||
h.fill(" ", 148, 156);
|
||||
h.write(t, 156, 1, "ascii");
|
||||
h.write("ustar\0" + "00", 257, 8, "ascii");
|
||||
let sum = 0;
|
||||
for (const b of h) sum += b;
|
||||
h.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii");
|
||||
return h;
|
||||
};
|
||||
if (Buffer.byteLength(name) <= 100) return block(name, type, size);
|
||||
// "<len> path=<name>\n", where <len> counts its own digits.
|
||||
const body = ` path=${name}\n`;
|
||||
let len = Buffer.byteLength(body);
|
||||
while (String(len).length + Buffer.byteLength(body) !== len) {
|
||||
len = String(len).length + Buffer.byteLength(body);
|
||||
}
|
||||
const rec = Buffer.from(`${len}${body}`, "utf8");
|
||||
return Buffer.concat([block("PaxHeader", "x", rec.length), tarPad(rec), block("", type, size)]);
|
||||
}
|
||||
|
||||
function tarPad(buf) {
|
||||
const rest = buf.length % 512;
|
||||
return rest ? Buffer.concat([buf, Buffer.alloc(512 - rest)]) : buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a batch streamed from `stream` (see above) into `dirRel`. Returns
|
||||
* `{saved, skipped, paths}`; under `conflict: "fail"` a taken name is 409
|
||||
* EEXISTS with every taken path in `names`.
|
||||
*/
|
||||
async function uploadBatch(dir, dirRel, stream, { conflict, trash } = {}) {
|
||||
const reader = byteReader(stream);
|
||||
try {
|
||||
const n = (await reader.read(4)).readUInt32BE(0);
|
||||
if (n > BATCH_HEAD_MAX) throw bad("ETOOBIG", "a batch's file list is too long");
|
||||
let head;
|
||||
try {
|
||||
head = JSON.parse((await reader.read(n)).toString("utf8"));
|
||||
} catch (e) {
|
||||
if (e.code) throw e;
|
||||
throw bad("EBADBODY", "a batch starts with its file list as JSON");
|
||||
}
|
||||
const files = batchFiles(head?.files);
|
||||
const policy = conflictOf(conflict);
|
||||
const plan = await planBatch(dir, dirRel, files, policy);
|
||||
if (policy === "fail" && plan.taken.length) {
|
||||
throw bad("EEXISTS", `${plan.taken.length} name(s) already exist`, { names: plan.taken });
|
||||
}
|
||||
const acct = accountAt(plan.base.abs);
|
||||
needExtract(acct);
|
||||
for (const f of plan.files) {
|
||||
if (f.replace) await trash(f.full);
|
||||
}
|
||||
const done = [];
|
||||
let skipped = 0;
|
||||
if (acct) {
|
||||
const tar = new PassThrough();
|
||||
const put = async (buf) => {
|
||||
// write-as gone (it failed): a destroyed stream never drains.
|
||||
if (tar.destroyed) throw bad("EWRITEAS", "the batch could not be written");
|
||||
if (!tar.write(buf)) await once(tar, "drain");
|
||||
};
|
||||
const now = Date.now();
|
||||
const extract = runAs.writeAsAsync(acct, plan.base.abs, ["extract"], { input: tar });
|
||||
const feed = (async () => {
|
||||
for (const d of plan.make) {
|
||||
const name = `${path.relative(plan.base.abs, d.abs).split(path.sep).join("/")}/`;
|
||||
await put(tarHeader(name, { type: "5", size: 0, mode: 0o755, mtime: now }));
|
||||
}
|
||||
for (const f of plan.files) {
|
||||
const data = await reader.read(f.size);
|
||||
if (f.skip) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const name = path.relative(plan.base.abs, f.abs).split(path.sep).join("/");
|
||||
await put(tarHeader(name, { type: "0", size: f.size, mode: 0o644, mtime: f.mtime }));
|
||||
await put(tarPad(data));
|
||||
done.push(f.full);
|
||||
}
|
||||
tar.end(Buffer.alloc(1024));
|
||||
})();
|
||||
await Promise.all([
|
||||
feed.catch((e) => {
|
||||
tar.destroy(e);
|
||||
throw e;
|
||||
}),
|
||||
extract.catch((e) => {
|
||||
tar.destroy(e);
|
||||
throw e;
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
for (const d of plan.make) {
|
||||
await fs.promises.mkdir(d.abs).catch(async (e) => {
|
||||
const st = e.code === "EEXIST" && (await fs.promises.lstat(d.abs).catch(() => null));
|
||||
if (!st?.isDirectory()) throw e;
|
||||
});
|
||||
}
|
||||
for (const f of plan.files) {
|
||||
const data = await reader.read(f.size);
|
||||
if (f.skip) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const tmp = path.join(
|
||||
f.up.abs,
|
||||
`.${f.name}.agenthub-upload-${crypto.randomBytes(4).toString("hex")}`
|
||||
);
|
||||
try {
|
||||
await fs.promises.writeFile(tmp, data, { flag: "wx" });
|
||||
const t = new Date(f.mtime);
|
||||
await fs.promises.utimes(tmp, t, t);
|
||||
// Linked, not renamed: a name taken meanwhile is refused, never replaced.
|
||||
await fs.promises.link(tmp, f.abs).catch(async (e) => {
|
||||
if (e.code === "EEXIST") {
|
||||
throw bad("EEXISTS", `${f.rel} already exists`, { names: [f.rel] });
|
||||
}
|
||||
if (e.code === "EPERM" || e.code === "ENOTSUP") return fs.promises.rename(tmp, f.abs);
|
||||
throw e;
|
||||
});
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { force: true });
|
||||
}
|
||||
done.push(f.full);
|
||||
}
|
||||
}
|
||||
return { saved: done.length, skipped, paths: done };
|
||||
} catch (e) {
|
||||
await reader.drain().catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One file for a download (the Files tab's `openDownload`, after this
|
||||
* module's own checks — the home rule included, on the file's REAL path).
|
||||
@@ -893,6 +1240,8 @@ module.exports = {
|
||||
restore,
|
||||
purgeTrash,
|
||||
upload,
|
||||
uploadBatch,
|
||||
uploadCheck,
|
||||
zip,
|
||||
collect,
|
||||
UPLOAD_MAX_BYTES,
|
||||
|
||||
@@ -866,8 +866,10 @@ async function cloneNow({ url, parent, name, once = null, partial = false }, thr
|
||||
const account = accountForDir(parent);
|
||||
const remote = remoteOf(url);
|
||||
// index-pack's delta threads: this clone's share of the cores, not all of
|
||||
// them (clone-queue.js). Not persisted — a later fetch keeps git's default.
|
||||
const pre = ["-c", `pack.threads=${threads}`];
|
||||
// them (clone-queue.js) — and as many checkout workers: a tree of many
|
||||
// small files is written one file at a time otherwise (~4x slower on 4
|
||||
// cores). Not persisted — a later fetch or checkout keeps git's default.
|
||||
const pre = ["-c", `pack.threads=${threads}`, "-c", `checkout.workers=${threads}`];
|
||||
const persist = [];
|
||||
const env = {};
|
||||
let restore = null;
|
||||
|
||||
@@ -301,6 +301,15 @@ function writeAsCanMove() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the installed write-as knows `extract` (a batch upload; install-system re-run). */
|
||||
function writeAsCanExtract() {
|
||||
try {
|
||||
return /^\s*extract\)/m.test(fs.readFileSync(WRITE_AS, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save `content` to `file` — as the member who owns the folder (S3), else
|
||||
* directly. In place, keeping the file's mode; `append` adds to the end.
|
||||
@@ -634,6 +643,7 @@ module.exports = {
|
||||
writeAs,
|
||||
writeAsAsync,
|
||||
writeAsCanMove,
|
||||
writeAsCanExtract,
|
||||
__setSpawnImpl,
|
||||
mkdirAs,
|
||||
makeMemberFolder,
|
||||
|
||||
@@ -149,6 +149,58 @@ const OPS = [
|
||||
status: 201,
|
||||
},
|
||||
],
|
||||
[
|
||||
"post",
|
||||
"/upload-check",
|
||||
"Before a batch: the names already taken, and whether a batch can go there",
|
||||
{
|
||||
note: "`taken`: which of `paths` (relative to `dir`) exist; `batch: false` = send the files one by one (a member's folder whose `write-as` predates `extract`).",
|
||||
body: json({ dir: { type: "string", default: "" }, paths: pathsArr }, ["paths"]),
|
||||
ok: {
|
||||
description: "`{taken, batch}`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { taken: pathsArr, batch: { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"post",
|
||||
"/upload-batch",
|
||||
"Upload many small files in one request (≤ 5,000 files, 16 MB each, 256 MB in all)",
|
||||
{
|
||||
note: "Body: a 4-byte big-endian length, that many bytes of JSON `{files: [{path, size, mtime?}]}` (paths relative to `dir`), then each file's bytes in that order. Planned whole before the first write — a taken name under `conflict=fail` is ONE 409 naming them all; a member's batch is one `write-as extract` (409 EUPGRADE until `agenthub server install-system` is re-run). 400 EBADBODY for a malformed or short body.",
|
||||
parameters: [
|
||||
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
|
||||
{ name: "conflict", in: "query", schema: conflict },
|
||||
],
|
||||
body: {
|
||||
required: true,
|
||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||
},
|
||||
status: 201,
|
||||
ok: {
|
||||
description: "`{saved, skipped, paths}`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
saved: { type: "integer" },
|
||||
skipped: { type: "integer" },
|
||||
paths: pathsArr,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"/zip",
|
||||
@@ -180,8 +232,8 @@ function operations(prefix, idParam, opPrefix, note) {
|
||||
out[key][method] = {
|
||||
tags: ["Files"],
|
||||
summary,
|
||||
description: `${note} ${errorCodes}`,
|
||||
operationId: `${opPrefix}${sub.replace(/\/(\w)/g, (_, c) => c.toUpperCase())}`,
|
||||
description: `${note} ${x.note ? `${x.note} ` : ""}${errorCodes}`,
|
||||
operationId: `${opPrefix}${sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`,
|
||||
parameters: [idParam, ...(x.parameters || [])],
|
||||
...(x.body ? { requestBody: x.body } : {}),
|
||||
responses: { [x.status || 200]: x.ok || { description: "Done." } },
|
||||
|
||||
@@ -393,6 +393,12 @@ function createOpenApiSpec() {
|
||||
name: { type: "string" },
|
||||
type: { type: "string", enum: ["main", "subagent", "compaction"] },
|
||||
subagent_type: { type: "string", nullable: true },
|
||||
tool_use_id: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"The Agent/Task tool_use that launched this subagent, from its meta file (Claude Code subagents only).",
|
||||
},
|
||||
has_transcript: { type: "boolean" },
|
||||
db_agent_id: {
|
||||
type: "string",
|
||||
|
||||
@@ -46,13 +46,19 @@ function sendError(res, err) {
|
||||
.json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
console.warn("[artifacts]", err);
|
||||
return res.status(500).json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
}
|
||||
|
||||
/** Tell clients a row changed: everyone when shared, else its owner (+ staff). */
|
||||
function changed(row) {
|
||||
if (!row) return;
|
||||
broadcast("artifact_changed", { id: row.id, ownerId: row.ownerId ?? row.owner_id ?? null, shared: !!(row.shared === true || row.shared === 1) });
|
||||
broadcast("artifact_changed", {
|
||||
id: row.id,
|
||||
ownerId: row.ownerId ?? row.owner_id ?? null,
|
||||
shared: !!(row.shared === true || row.shared === 1),
|
||||
});
|
||||
}
|
||||
|
||||
const wrap = (fn) => async (req, res) => {
|
||||
@@ -101,7 +107,9 @@ router.put(
|
||||
}
|
||||
const on = req.body?.installsEnabled;
|
||||
if (typeof on !== "boolean") {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } });
|
||||
}
|
||||
artifacts.setInstallsEnabled(on);
|
||||
if (req.user) {
|
||||
@@ -230,7 +238,9 @@ router.post(
|
||||
wrap((req, res) => {
|
||||
const f = (req.files || [])[0];
|
||||
if (!f) {
|
||||
return res.status(400).json({ error: { code: "ENOFILE", message: "attach the file as `files`" } });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOFILE", message: "attach the file as `files`" } });
|
||||
}
|
||||
const item = artifacts.uploadFile(req, {
|
||||
name: f.originalname,
|
||||
@@ -264,7 +274,8 @@ router.patch(
|
||||
wrap((req, res) => {
|
||||
const item = artifacts.update(req, req.params.id, req.body || {});
|
||||
changed(item);
|
||||
res.json({ item });
|
||||
// `removed`: unsharing a Hub-only row deleted it.
|
||||
res.json(item.removed ? { item, removed: true } : { item });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -274,7 +285,8 @@ router.delete(
|
||||
wrap((req, res) => {
|
||||
const row = artifacts.remove(req, req.params.id);
|
||||
changed(row);
|
||||
res.json({ ok: true });
|
||||
// `kept`: a shared row left the owner's library but stays in the Hub.
|
||||
res.json({ ok: true, kept: !!row.kept });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -318,7 +330,8 @@ router.get(
|
||||
const download = req.query.download === "1" || !type;
|
||||
res.set({
|
||||
"Content-Type": download ? "application/octet-stream" : type,
|
||||
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; media-src 'self'; sandbox",
|
||||
"Content-Security-Policy":
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; media-src 'self'; sandbox",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, max-age=60",
|
||||
...(download
|
||||
@@ -331,6 +344,12 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
// Who installed it, when and where (`artifacts.installsOf`).
|
||||
router.get(
|
||||
"/:id/installs",
|
||||
wrap((req, res) => res.json(artifacts.installsOf(req, req.params.id)))
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/install",
|
||||
sameOriginGuard,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* GET /api/homes/trash every Trash the caller may open, merged
|
||||
* GET /api/homes/:who/fs/list|trash|zip|download
|
||||
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||
* POST /api/homes/:who/fs/upload-batch|upload-check many small files in one request
|
||||
*
|
||||
* `:who` is `me`, or — for an admin — another user's name: read-only
|
||||
* (`403 EREADONLY` on every write) and written to the audit log. The one
|
||||
@@ -36,6 +37,7 @@ function fail(res, err) {
|
||||
ENOHOME: 404,
|
||||
EBADPATH: 400,
|
||||
EBADNAME: 400,
|
||||
EBADBODY: 400,
|
||||
ENOFILE: 404,
|
||||
ESECRET: 403,
|
||||
EFOREIGN: 403,
|
||||
@@ -314,5 +316,38 @@ router.post(
|
||||
}
|
||||
}, W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/upload-batch",
|
||||
sameOriginGuard,
|
||||
handle(async (req, res, home) => {
|
||||
try {
|
||||
const out = await fx.uploadBatch(
|
||||
home.dir,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: trashFor(req, home) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
throw err;
|
||||
}
|
||||
}, W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/upload-check",
|
||||
sameOriginGuard,
|
||||
handle(
|
||||
async (req, res, home) =>
|
||||
res.json(
|
||||
await fx.uploadCheck(
|
||||
home.dir,
|
||||
typeof req.body?.dir === "string" ? req.body.dir : "",
|
||||
req.body?.paths
|
||||
)
|
||||
),
|
||||
W
|
||||
)
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1586,6 +1586,7 @@ function fsFail(res, err) {
|
||||
{
|
||||
EBADPATH: 400,
|
||||
EBADNAME: 400,
|
||||
EBADBODY: 400,
|
||||
ENOFILE: 404,
|
||||
ESECRET: 403,
|
||||
EFOREIGN: 403,
|
||||
@@ -1782,6 +1783,40 @@ router.post("/:id/fs/upload", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Many small files in ONE request (`fileExplorer.uploadBatch`): `?dir=` is the
|
||||
* folder their paths are relative to. A batch is planned whole before the
|
||||
* first write, so a taken name under `conflict=fail` is one 409 naming them all.
|
||||
*/
|
||||
router.post("/:id/fs/upload-batch", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const out = await fileExplorer.uploadBatch(
|
||||
t.cwd,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Before a batch: `{taken, batch}` for `{dir, paths}` — no body bytes wasted on a 409. */
|
||||
router.post("/:id/fs/upload-check", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const dir = typeof req.body?.dir === "string" ? req.body.dir : "";
|
||||
res.json(await fileExplorer.uploadCheck(t.cwd, dir, req.body?.paths));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Files and folders as one zip, streamed (`?p=` once per path). */
|
||||
router.get("/:id/fs/zip", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
|
||||
+66
-10
@@ -29,7 +29,7 @@ const path = require("node:path");
|
||||
const runs = require("../lib/pty-run");
|
||||
const tmux = require("../lib/tmux");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { sameOriginGuard, isLocalRequest } = require("../lib/same-origin");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
const scope = require("../lib/scope");
|
||||
@@ -115,6 +115,20 @@ const DROP_TYPES = {
|
||||
};
|
||||
const DROP_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const DROP_TTL_MS = 24 * 60 * 60_000;
|
||||
/** A body declared over the limit is refused before a byte of it is read, and
|
||||
* the connection is closed: express.raw would read (and discard) all of it
|
||||
* first, so a 4 GB drop crossed the network in full just to be told 413. */
|
||||
function refuseOversize(req, res, next) {
|
||||
if (!(Number(req.headers["content-length"]) > DROP_MAX_BYTES)) return next();
|
||||
res.set("Connection", "close");
|
||||
res.status(413).json({
|
||||
error: {
|
||||
code: "ETOOBIG",
|
||||
message: `a dropped file may be at most ${DROP_MAX_BYTES / 1024 / 1024} MB`,
|
||||
max_bytes: DROP_MAX_BYTES,
|
||||
},
|
||||
});
|
||||
}
|
||||
/** Where a drop goes: the handoff dir of the account the PANE runs as — the
|
||||
* workspace `?laneId=` names (scopeGuard already refused one this user may
|
||||
* not see), else the requester's own — written AS that member under S3, so
|
||||
@@ -164,6 +178,7 @@ function saveDrop(req, res, name, body) {
|
||||
|
||||
router.post(
|
||||
"/drop-image",
|
||||
refuseOversize,
|
||||
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const ext =
|
||||
@@ -183,16 +198,57 @@ router.post(
|
||||
// so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a
|
||||
// random prefix, so it can neither leave the drop dir nor collide, and the
|
||||
// space-joined paste of several paths stays parseable.
|
||||
router.post("/drop-file", express.raw({ type: () => true, limit: DROP_MAX_BYTES }), (req, res) => {
|
||||
const safe = path
|
||||
.basename(String(req.query.name || ""))
|
||||
.replace(/[^A-Za-z0-9._-]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(-100);
|
||||
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
router.post(
|
||||
"/drop-file",
|
||||
refuseOversize,
|
||||
express.raw({ type: () => true, limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const safe = path
|
||||
.basename(String(req.query.name || ""))
|
||||
.replace(/[^A-Za-z0-9._-]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(-100);
|
||||
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
}
|
||||
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
|
||||
}
|
||||
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
|
||||
);
|
||||
|
||||
// A browser on this machine is handed a file manager's files as `file://`
|
||||
// paths beside their bytes; the composer attaches such a path instead of
|
||||
// uploading — but only once this answers that the path is a regular file of
|
||||
// the dropped size HERE. "localhost" in the address bar is not proof: an SSH
|
||||
// tunnel's browser is on another machine, and a server-mode pane runs as an
|
||||
// account that cannot read the host's files, so server mode and anything not
|
||||
// from loopback (a proxy counts as remote) get 403 and the client uploads.
|
||||
router.post("/local-paths", (req, res) => {
|
||||
if (require("../lib/auth").isServerMode() || !isLocalRequest(req)) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EREMOTE", message: "local paths are checked only for a local browser" },
|
||||
});
|
||||
}
|
||||
const paths = req.body?.paths;
|
||||
if (
|
||||
!Array.isArray(paths) ||
|
||||
paths.length === 0 ||
|
||||
paths.length > 100 ||
|
||||
!paths.every((p) => typeof p === "string" && path.isAbsolute(p))
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADFIELD", message: "paths must be 1-100 absolute paths" },
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
sizes: paths.map((p) => {
|
||||
try {
|
||||
const st = fs.statSync(p);
|
||||
return st.isFile() ? st.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/history", (req, res) => {
|
||||
|
||||
@@ -846,7 +846,13 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
const omp = require("../lib/omp-usage");
|
||||
const list = [];
|
||||
if (omp.journalPath(req.params.id)) {
|
||||
list.push({ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null });
|
||||
list.push({
|
||||
id: "main",
|
||||
name: "Main Agent",
|
||||
type: "main",
|
||||
has_transcript: true,
|
||||
db_agent_id: null,
|
||||
});
|
||||
}
|
||||
for (const j of omp.subagentJournals(req.params.id)) {
|
||||
list.push({
|
||||
@@ -973,6 +979,8 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
name: transcriptName,
|
||||
type: isCompact ? "compaction" : "subagent",
|
||||
subagent_type: transcriptSubagentType,
|
||||
// The Agent call that launched it: the chat opens it from that call.
|
||||
tool_use_id: typeof meta?.toolUseId === "string" ? meta.toolUseId : null,
|
||||
has_transcript: true,
|
||||
db_agent_id: null, // matched later after all transcripts are collected
|
||||
_timestamp: transcriptTimestamp,
|
||||
@@ -1260,18 +1268,32 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||
const diff = patchCounts(entry.toolUseResult);
|
||||
const answers = askAnswers(entry.toolUseResult);
|
||||
const agent = agentInfo(entry.toolUseResult);
|
||||
// A subagent's report is a reply, read as prose: its text blocks
|
||||
// whole, not a 10 KB slice of their JSON.
|
||||
const report =
|
||||
agent && Array.isArray(block.content)
|
||||
? block.content
|
||||
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n\n")
|
||||
: null;
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: block.tool_use_id || null,
|
||||
output: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
output:
|
||||
report !== null
|
||||
? truncate(report, TEXT_MAX)
|
||||
: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
is_error: !!block.is_error,
|
||||
...(diff ? { diff } : {}),
|
||||
...(answers ? { answers } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1574,7 +1596,10 @@ function ompMessage(entry, num, isSubagent = false) {
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: c.id || null,
|
||||
input: truncateObj(c.arguments && typeof c.arguments === "object" ? c.arguments : {}, 10240),
|
||||
input: truncateObj(
|
||||
c.arguments && typeof c.arguments === "object" ? c.arguments : {},
|
||||
10240
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1670,6 +1695,24 @@ function askAnswers(r) {
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
|
||||
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
|
||||
* finished in the foreground, its time, tokens and tool calls. Null for any
|
||||
* other result. */
|
||||
function agentInfo(r) {
|
||||
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
|
||||
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
return {
|
||||
id: r.agentId,
|
||||
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
|
||||
status: typeof r.status === "string" ? r.status : null,
|
||||
duration_ms: num(r.totalDurationMs),
|
||||
tokens: num(r.totalTokens),
|
||||
tool_uses: num(r.totalToolUseCount),
|
||||
};
|
||||
}
|
||||
|
||||
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
||||
// the model's output and what the user typed. Only tool payloads and thinking
|
||||
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
||||
@@ -1694,3 +1737,4 @@ module.exports.sessionStats = sessionStats;
|
||||
module.exports.classifyTranscriptSender = classifyTranscriptSender;
|
||||
module.exports.patchCounts = patchCounts;
|
||||
module.exports.askAnswers = askAnswers;
|
||||
module.exports.agentInfo = agentInfo;
|
||||
|
||||
Reference in New Issue
Block a user