feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars

- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
+181 -1
View File
@@ -4,7 +4,11 @@
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
* symlink/secret refusal, install into the caller's Claude home with
* replace moving the old one aside), plugins (marketplace allowlist, install
* argv), sharing refused in local mode, visibility for a server-mode
* 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
* file, a folder, a zip, several skills at once — name and frontmatter set,
* junk skipped, nothing stored when one fails) or written in the dialog, a
* file uploaded, sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, and same-origin writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -219,6 +223,182 @@ describe("plugins", () => {
});
});
function upload(p, files, fields = {}) {
const form = new FormData();
const paths = [];
for (const [name, content] of files) {
form.append("files", new Blob([content]), name.split("/").pop());
paths.push(name);
}
form.append("paths", JSON.stringify(paths));
for (const [k, v] of Object.entries(fields)) form.append(k, v);
return fetch(`${base}${p}`, { method: "POST", body: form }).then(async (r) => ({
status: r.status,
body: await r.json(),
}));
}
const snapText = (item, file = "SKILL.md") =>
fs.readFileSync(
path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot, file),
"utf8"
);
describe("plugins from a marketplace link", () => {
const MARKET = JSON.stringify({
name: "sg",
description: "Smartgift skills",
plugins: [
{ name: "sg-dev", source: "./plugins/dev", description: "Dev team" },
{ name: "sg-common", source: "./plugins/common" },
{ name: "-bad", source: "./x" },
],
});
it("normalizes what a user pastes into a marketplace source", () => {
const n = artifacts.normalizeMarket;
assert.equal(n("https://github.com/o/r"), "o/r");
assert.equal(n("https://github.com/o/r.git/"), "o/r");
assert.equal(
n("https://git.example.vn/Org/skills/src/branch/main/"),
"https://git.example.vn/Org/skills"
);
assert.equal(n("https://git.example.vn/Org/skills.git"), "https://git.example.vn/Org/skills.git");
assert.throws(() => n("file:///etc"), { code: "EBADMARKET" });
assert.throws(() => n("http://x/y.git"), { code: "EBADMARKET" });
});
it("lists a marketplace's plugins and adds a picked one once", async () => {
let asked = null;
artifacts.__setInspectImpl(async (m) => {
asked = m;
return MARKET;
});
const r = await request("POST", "/api/artifacts/plugin/inspect", {
market: "https://git.example.vn/Org/skills.git",
});
artifacts.__setInspectImpl(null);
assert.equal(r.status, 200, r.text);
assert.equal(asked, "https://git.example.vn/Org/skills.git");
assert.equal(r.body.name, "sg");
assert.deepEqual(r.body.plugins.map((p) => p.key), ["sg-dev@sg", "sg-common@sg"]);
const add = (title) =>
request("POST", "/api/artifacts/plugin", { key: "sg-dev@sg", market: r.body.market, title });
const a = await add("Dev");
const b = await add("Dev team");
assert.equal(a.status, 201, a.text);
assert.equal(b.body.item.id, a.body.item.id);
assert.equal(b.body.item.title, "Dev team");
const edit = await request("PATCH", `/api/artifacts/${a.body.item.id}`, {
source: "sg-common@sg",
market: "https://github.com/o/r",
});
assert.equal(edit.status, 200, edit.text);
assert.equal(edit.body.item.source, "sg-common@sg");
assert.equal(edit.body.item.market, "o/r");
const bad = await request("PATCH", `/api/artifacts/${a.body.item.id}`, { source: "nope" });
assert.equal(bad.body.error.code, "EBADPLUGIN");
});
it("refuses a marketplace.json with no usable name", async () => {
artifacts.__setInspectImpl(async () => "{}");
const r = await request("POST", "/api/artifacts/plugin/inspect", { market: "o/r" });
artifacts.__setInspectImpl(null);
assert.equal(r.body.error.code, "EBADMARKET");
});
});
describe("skills from files or written", () => {
it("makes one Markdown file the SKILL.md, named by the user, with a frontmatter", async () => {
const r = await upload("/api/artifacts/skill/upload", [["Notes cho đội.md", "# Do this\nStep 1\n"]], {
name: "Hướng dẫn deploy",
description: "Use when deploying",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const [item] = r.body.items;
assert.equal(item.source, "Huong-dan-deploy");
assert.deepEqual(item.files.map((f) => f.path), ["SKILL.md"]);
const text = snapText(item);
assert.match(text, /^---\nname: Huong-dan-deploy\ndescription: Use when deploying\n---\n/);
assert.match(text, /# Do this/);
});
it("refuses a skill with no description anywhere", async () => {
const r = await upload("/api/artifacts/skill/upload", [["x.md", "body"]], { name: "x" });
assert.equal(r.body.error.code, "ENODESCRIPTION");
});
it("takes a picked folder: the top folder is stripped, junk skipped, the name from the frontmatter", async () => {
const r = await upload("/api/artifacts/skill/upload", [
["my-skill/SKILL.md", "---\nname: release-notes\ndescription: Write notes\n---\nGo."],
["my-skill/scripts/run.sh", "echo"],
["my-skill/.DS_Store", "junk"],
]);
assert.equal(r.status, 201, JSON.stringify(r.body));
const [item] = r.body.items;
assert.equal(item.source, "release-notes");
assert.equal(item.description, "Write notes");
assert.deepEqual(item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
});
it("unpacks a zip holding several skills into one artifact each", async () => {
const AdmZip = require("adm-zip");
const zip = new AdmZip();
zip.addFile("pack/alpha/SKILL.md", Buffer.from("---\nname: alpha\ndescription: A\n---\n"));
zip.addFile("pack/alpha/ref/more.md", Buffer.from("x"));
zip.addFile("pack/beta/skill.md", Buffer.from("---\ndescription: B\n---\nb"));
zip.addFile("__MACOSX/pack/._alpha", Buffer.from("junk"));
const r = await upload("/api/artifacts/skill/upload", [["pack.zip", zip.toBuffer()]]);
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.deepEqual(r.body.items.map((i) => i.source).sort(), ["alpha", "beta"]);
const beta = r.body.items.find((i) => i.source === "beta");
assert.match(snapText(beta), /^---\nname: beta\ndescription: B\n---/);
});
it("stores nothing when one skill of an upload is bad, and refuses a secret or a path out", async () => {
const before = db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n;
const r = await upload("/api/artifacts/skill/upload", [
["a/SKILL.md", "---\ndescription: A\n---\n"],
["b/SKILL.md", "no frontmatter"],
]);
assert.equal(r.body.error.code, "ENODESCRIPTION");
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n, before);
const secret = await upload(
"/api/artifacts/skill/upload",
[["s/SKILL.md", "---\ndescription: S\n---\n"], ["s/.env", "K=1"]]
);
assert.equal(secret.body.error.code, "ESECRET");
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], { description: "d" });
assert.equal(out.body.error.code, "EBADPATH");
const none = await upload("/api/artifacts/skill/upload", [["a.png", "x"], ["b.png", "y"]]);
assert.equal(none.body.error.code, "ENOSKILLMD");
});
it("writes a skill from the dialog", async () => {
const r = await request("POST", "/api/artifacts/skill/new", {
name: "standup",
description: "Use for the daily standup: summarise yesterday",
content: "Summarise.",
});
assert.equal(r.status, 201, r.text);
assert.match(snapText(r.body.item), /^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./);
const bad = await request("POST", "/api/artifacts/skill/new", { name: "!!!", description: "d", content: "c" });
assert.equal(bad.body.error.code, "EBADNAME");
});
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], { title: "Report" });
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.item.kind, "file");
assert.equal(r.body.item.title, "Report");
const raw = await request("GET", `/api/artifacts/${r.body.item.id}/raw`);
assert.equal(raw.text, "<h1>x</h1>");
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
});
});
describe("sharing and visibility", () => {
it("refuses to share in local mode", async () => {
const item = (await request("GET", "/api/artifacts")).body.items[0];