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:
@@ -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];
|
||||
|
||||
@@ -120,6 +120,28 @@ describe("a pool login", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("is a host pane's account when it is the host's own login, and nothing is written", () => {
|
||||
const hostCreds = path.join(TMP, ".claude", ".credentials.json");
|
||||
fs.mkdirSync(path.dirname(hostCreds), { recursive: true });
|
||||
fs.writeFileSync(hostCreds, JSON.stringify({ claudeAiOauth: oauth("h") }));
|
||||
try {
|
||||
pool.addLoginAccount("mine", { oauth: oauth("h"), account: ACCOUNT, hostHolds: true });
|
||||
const spawn = pool.forSpawn(null);
|
||||
assert.equal(spawn.name, "mine", "the host pane's usage is booked to it");
|
||||
assert.deepEqual(spawn.env, {});
|
||||
spawn.place();
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(hostCreds, "utf8")).claudeAiOauth.refreshToken,
|
||||
oauth("h").refreshToken
|
||||
);
|
||||
// The host logged in as someone else: no longer the pool's.
|
||||
fs.writeFileSync(hostCreds, JSON.stringify({ claudeAiOauth: oauth("other") }));
|
||||
assert.equal(pool.forSpawn(null), null);
|
||||
} finally {
|
||||
fs.rmSync(hostCreds, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("is never written over a member's own login", () => {
|
||||
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||
fs.mkdirSync(path.dirname(CREDS), { recursive: true });
|
||||
|
||||
@@ -27,21 +27,33 @@ const pty = require("../lib/pty-run");
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
const realFetchUsage = ccUsage.fetchUsage;
|
||||
|
||||
function run(id, account, { status = "completed", session = null, started } = {}) {
|
||||
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account)
|
||||
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?)`
|
||||
).run(id, session, status, started || new Date().toISOString(), account);
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, ended_at, account)
|
||||
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?, ?)`
|
||||
).run(id, session, status, started || new Date().toISOString(), ended, account);
|
||||
}
|
||||
|
||||
function spend(session, tokens) {
|
||||
const MODEL = "claude-sonnet-4-5-20250929";
|
||||
const hoursAgo = (h) => new Date(Date.now() - h * 3600e3).toISOString();
|
||||
|
||||
/**
|
||||
* `tokens` output tokens of `session` spent `atHoursAgo` hours ago — the
|
||||
* hourly row plus the session total, as the hook writer keeps them both.
|
||||
* `{hourly: false}` = a total with no hourly row (a sub-agent's bucket).
|
||||
*/
|
||||
function spend(session, tokens, { atHoursAgo = 0, hourly = true, model = MODEL } = {}) {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status, cwd) VALUES (?, ?, 'completed', '/tmp')"
|
||||
).run(session, session);
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status, cwd, started_at) VALUES (?, ?, 'completed', '/tmp', ?)"
|
||||
).run(session, session, hoursAgo(atHoursAgo));
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(session, new Date().toISOString().slice(0, 13), tokens);
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens) VALUES (?, ?, ?)
|
||||
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
|
||||
).run(session, model, tokens);
|
||||
if (!hourly) return;
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES (?, ?, ?, ?)`
|
||||
).run(session, hoursAgo(atHoursAgo).slice(0, 13), model, tokens);
|
||||
}
|
||||
|
||||
before(() => {
|
||||
@@ -62,6 +74,8 @@ describe("claude pool", () => {
|
||||
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
|
||||
db.prepare("DELETE FROM dashboard_runs").run();
|
||||
db.prepare("DELETE FROM token_usage_hourly").run();
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM sessions").run();
|
||||
pool.__quotaCache.clear();
|
||||
});
|
||||
|
||||
@@ -109,6 +123,26 @@ describe("claude pool", () => {
|
||||
assert.equal(pool.pickAccount().name, "b");
|
||||
});
|
||||
|
||||
it("books a session to an account only for the hours its runs held it, sub-agents included", () => {
|
||||
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
|
||||
// s1 ran on a 30 h ago, then was resumed on b 2 h ago.
|
||||
run("r-a", "a", { session: "s1", started: hoursAgo(30), ended: hoursAgo(29) });
|
||||
run("r-b", "b", { session: "s1", started: hoursAgo(2), status: "running" });
|
||||
spend("s1", 1000, { atHoursAgo: 30 });
|
||||
spend("s1", 3000, { atHoursAgo: 2 });
|
||||
// s2 (on a, now): its main agent and a Haiku sub-agent with no hourly row.
|
||||
run("r-a2", "a", { session: "s2" });
|
||||
spend("s2", 500);
|
||||
spend("s2", 200, { hourly: false, model: "claude-haiku-4-5-20251001" });
|
||||
const status = Object.fromEntries(pool.poolStatus().map((x) => [x.name, x]));
|
||||
assert.equal(status.a.tokens5h, 700, "s2, sub-agent included; s1 is b's now");
|
||||
assert.equal(status.a.tokens7d, 1700);
|
||||
assert.equal(status.b.tokens5h, 3000, "not the hours a held s1");
|
||||
assert.equal(status.b.tokens7d, 3000);
|
||||
assert.ok(status.a.spend7d > status.a.spend5h && status.a.spend5h > 0);
|
||||
assert.ok(status.b.spend5h > 0);
|
||||
});
|
||||
|
||||
it("puts an account whose quota is nearly used up last", async () => {
|
||||
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
|
||||
ccUsage.fetchUsage = async (token) => ({
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file me-avatar.test.js
|
||||
* @description Settings → Account's avatar (`/api/me/avatar`,
|
||||
* server/lib/avatars.js): an account sets, reads and removes its own picture;
|
||||
* only PNG/JPEG/GIF/WebP by magic bytes (an SVG is refused), at most 1 MB,
|
||||
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
|
||||
* another account never sees it through `/api/me`; staff read it at
|
||||
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
|
||||
* `owner_avatar_at` for the sidebar's owner groups.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "me-avatar-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
const PNG = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.alloc(32, 1),
|
||||
]);
|
||||
const JPG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(32, 2)]);
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
const put = (cookie, body) =>
|
||||
fetch(BASE + "/api/me/avatar", {
|
||||
method: "PUT",
|
||||
headers: { cookie, "Content-Type": "application/octet-stream" },
|
||||
body,
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("ava", "ava-pass-1", "developer");
|
||||
await auth.createUser("bob", "bob-pass-1", "developer");
|
||||
await auth.createUser("ann", "ann-pass-1", "analyst");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("sets, serves, replaces and removes the account's own avatar", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const bob = await login("bob", "bob-pass-1");
|
||||
assert.equal((await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava } })).status, 404);
|
||||
|
||||
const set = await put(ava, PNG);
|
||||
assert.equal(set.status, 200);
|
||||
const at = (await set.json()).user.avatarAt;
|
||||
assert.ok(at);
|
||||
const me = await (await fetch(BASE + "/api/auth/me", { headers: { cookie: ava } })).json();
|
||||
assert.equal(me.user.avatarAt, at);
|
||||
|
||||
const got = await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava } });
|
||||
assert.equal(got.status, 200);
|
||||
assert.equal(got.headers.get("content-type"), "image/png");
|
||||
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.match(got.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
|
||||
// Another account sees only its own (none).
|
||||
assert.equal((await fetch(BASE + "/api/me/avatar", { headers: { cookie: bob } })).status, 404);
|
||||
|
||||
// A JPEG replaces the PNG — one file per account.
|
||||
assert.equal((await put(ava, JPG)).status, 200);
|
||||
const files = fs.readdirSync(path.join(TMP, "avatars"));
|
||||
assert.deepEqual(files, [files.find((f) => f.endsWith(".jpg"))]);
|
||||
|
||||
const del = await fetch(BASE + "/api/me/avatar", { method: "DELETE", headers: { cookie: ava } });
|
||||
assert.equal(del.status, 200);
|
||||
assert.equal((await del.json()).user.avatarAt, null);
|
||||
assert.equal(fs.readdirSync(path.join(TMP, "avatars")).length, 0);
|
||||
});
|
||||
|
||||
it("refuses an SVG, an empty body, a too-large image and no login", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const svg = await put(ava, Buffer.from("<svg xmlns='http://www.w3.org/2000/svg'/>"));
|
||||
assert.equal(svg.status, 400);
|
||||
assert.equal((await svg.json()).error.code, "EBADIMAGE");
|
||||
assert.equal((await put(ava, Buffer.alloc(0))).status, 400);
|
||||
const big = await put(ava, Buffer.concat([PNG, Buffer.alloc(1024 * 1024)]));
|
||||
assert.equal(big.status, 413);
|
||||
assert.equal((await big.json()).error.code, "ETOOBIG");
|
||||
assert.equal((await put("", PNG)).status, 401);
|
||||
});
|
||||
|
||||
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const ann = await login("ann", "ann-pass-1");
|
||||
const bob = await login("bob", "bob-pass-1");
|
||||
const id = auth.findUser("ava").id;
|
||||
const { db } = require("../db");
|
||||
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/ava', 'claude', ?)").run(id);
|
||||
assert.equal((await put(ava, PNG)).status, 200);
|
||||
const got = await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: ann } });
|
||||
assert.equal(got.status, 200);
|
||||
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
|
||||
assert.equal(
|
||||
(await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: bob } })).status,
|
||||
403
|
||||
);
|
||||
const lanes = (await (await fetch(BASE + "/api/lanes", { headers: { cookie: ann } })).json())
|
||||
.lanes;
|
||||
assert.ok(lanes.find((l) => l.cwd === "/w/ava").owner_avatar_at);
|
||||
});
|
||||
|
||||
it("deleting the account deletes its avatar", async () => {
|
||||
const bob = await login("bob", "bob-pass-1");
|
||||
assert.equal((await put(bob, PNG)).status, 200);
|
||||
auth.deleteUser("bob");
|
||||
assert.ok(!fs.readdirSync(path.join(TMP, "avatars")).some((f) => f.startsWith("u2.")));
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file user-storage.test.js
|
||||
* @description The Users page's Storage column (server/lib/user-storage.js,
|
||||
* `GET /api/users/storage`): the walk counts allocated blocks, a hard link
|
||||
* once and never follows a symlink; a server-mode user's folder under
|
||||
* AGENTHUB_USERS_ROOT is measured in the background and reported to staff,
|
||||
* a member is refused, and a user with no folder of their own reads null.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "user-storage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
const USERS_ROOT = path.join(TMP, "users");
|
||||
fs.mkdirSync(USERS_ROOT);
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const auth = require("../lib/auth");
|
||||
const { walk } = require("../lib/user-storage");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const ids = {};
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
async function storage(cookie, q = "") {
|
||||
const res = await fetch(`${BASE}/api/users/storage${q}`, { headers: { cookie } });
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS_ROOT;
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", "admin-pass-1", "admin")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
const home = path.join(USERS_ROOT, "mia");
|
||||
fs.mkdirSync(path.join(home, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, "src", "a.bin"), Buffer.alloc(64 * 1024, 1));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("walk", () => {
|
||||
it("counts blocks, a hard link once, and never follows a symlink", () => {
|
||||
const dir = fs.mkdtempSync(path.join(TMP, "walk-"));
|
||||
fs.writeFileSync(path.join(dir, "big"), Buffer.alloc(128 * 1024, 1));
|
||||
fs.linkSync(path.join(dir, "big"), path.join(dir, "big-link"));
|
||||
fs.symlinkSync("/usr", path.join(dir, "usr"));
|
||||
const r = walk(dir, Date.now() + 10_000);
|
||||
assert.equal(r.partial, false);
|
||||
assert.equal(r.files, 2); // big + the symlink itself; the hard link is skipped
|
||||
assert.ok(r.bytes >= 128 * 1024 && r.bytes < 1024 * 1024, `bytes ${r.bytes}`);
|
||||
});
|
||||
|
||||
it("stops at the deadline as a lower bound", () => {
|
||||
const r = walk(TMP, Date.now() - 1);
|
||||
assert.equal(r.partial, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/users/storage", () => {
|
||||
it("measures a user's folder in the background for staff", async () => {
|
||||
const admin = await login("boss", "admin-pass-1");
|
||||
let r = await storage(admin);
|
||||
assert.equal(r.status, 200);
|
||||
for (let i = 0; i < 50 && (r.body.measuring || !r.body.storage[ids.mia]); i++) {
|
||||
await new Promise((ok) => setTimeout(ok, 100));
|
||||
r = await storage(admin);
|
||||
}
|
||||
const mia = r.body.storage[ids.mia];
|
||||
assert.ok(mia, "mia's folder was measured");
|
||||
assert.ok(mia.bytes >= 64 * 1024);
|
||||
assert.equal(mia.files, 1);
|
||||
assert.equal(mia.partial, false);
|
||||
assert.ok(mia.measuredAt);
|
||||
// The admin has no folder under the users root: nothing to measure.
|
||||
assert.equal(r.body.storage[ids.boss], null);
|
||||
});
|
||||
|
||||
it("refuses a developer", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const r = await storage(mia);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
+4
-2
@@ -1855,8 +1855,10 @@ try {
|
||||
}
|
||||
}
|
||||
// An account's own display name and email (the Users page's Edit user and
|
||||
// Settings → Account). Additive; after the rebuild above, which predates them.
|
||||
for (const col of ["display_name", "email"]) {
|
||||
// Settings → Account), and when its avatar was last set (server/lib/avatars.js
|
||||
// — the image is a file, the column only versions its URL). Additive; after
|
||||
// the rebuild above, which predates them.
|
||||
for (const col of ["display_name", "email", "avatar_at"]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||
} catch {
|
||||
|
||||
+489
-9
@@ -382,6 +382,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A file uploaded from the user's computer: kept as a snapshot like a pinned
|
||||
* one, under a name made from the upload's own (no folder, no path).
|
||||
*/
|
||||
function uploadFile(req, { name, data, title, description }) {
|
||||
if (!Buffer.isBuffer(data) || !data.length) throw fail("ENOFILE", "the file is empty");
|
||||
if (data.length > FILE_MAX_BYTES) throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
||||
const base = path.posix.basename(String(name || "").replace(/\\/g, "/"));
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const clean = base.replace(/[\x00-\x1f/]/g, "").replace(/^\.+/, "").slice(0, 200) || "file";
|
||||
const snap = newSnapshotDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(snap.dir, clean), data, { mode: 0o600 });
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
throw err;
|
||||
}
|
||||
return view(
|
||||
insert({
|
||||
kind: "file",
|
||||
title: cleanTitle(title, clean),
|
||||
description: cleanDescription(description),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
source: clean,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify([{ path: clean, size: data.length }]),
|
||||
size: data.length,
|
||||
}),
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
// ── skill ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -499,16 +531,45 @@ function addSkill(req, { name, laneId, title, description }) {
|
||||
throw fail("ESYMLINK", "the skill folder is a link elsewhere");
|
||||
}
|
||||
if (!fs.existsSync(path.join(real, "SKILL.md"))) throw fail("ENOFILE", "no SKILL.md in that folder");
|
||||
const { files, total } = walkSkill(real);
|
||||
const { files } = walkSkill(real);
|
||||
const runAs = require("./run-as");
|
||||
return storeSkill(req, {
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
laneId: lane ? lane.id : null,
|
||||
files: files.map((f) => ({
|
||||
path: f.path,
|
||||
data: () => runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** `description:` of a SKILL.md's frontmatter, or "". */
|
||||
function frontmatterField(text, field) {
|
||||
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
||||
if (!fm) return "";
|
||||
const m = new RegExp(`^${field}:\\s*(.*)$`, "m").exec(fm[1]);
|
||||
return (m?.[1] || "").trim().replace(/^(["'])(.*)\1$/, "$2");
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot a skill's files and record the row. `files`: `[{path, data}]`,
|
||||
* `data` a Buffer or a function returning one (read only while copying).
|
||||
*/
|
||||
function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const recorded = [];
|
||||
try {
|
||||
for (const f of files) {
|
||||
const data = typeof f.data === "function" ? f.data() : f.data;
|
||||
total += data.length;
|
||||
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
const target = path.join(snap.dir, ...f.path.split("/"));
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(target, data, { mode: 0o600 });
|
||||
recorded.push({ path: f.path, size: data.length });
|
||||
}
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
@@ -516,8 +577,7 @@ function addSkill(req, { name, laneId, title, description }) {
|
||||
}
|
||||
let summary = "";
|
||||
try {
|
||||
const head = fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8");
|
||||
summary = /^description:\s*(.+)$/m.exec(head)?.[1]?.trim() || "";
|
||||
summary = frontmatterField(fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"), "description");
|
||||
} catch {
|
||||
/* no frontmatter */
|
||||
}
|
||||
@@ -527,16 +587,263 @@ function addSkill(req, { name, laneId, title, description }) {
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
lane_id: lane ? lane.id : null,
|
||||
lane_id: laneId,
|
||||
source: name,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify(files.map((f) => ({ path: f.path, size: f.size }))),
|
||||
files: JSON.stringify(recorded),
|
||||
size: total,
|
||||
}),
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
// ── skill: written or uploaded ────────────────────────────────────────────
|
||||
|
||||
// Junk an OS or an editor leaves in a folder or a zip: skipped, never an error.
|
||||
const JUNK_RE = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
|
||||
const ZIP_RE = /\.(zip|skill)$/i;
|
||||
const TEXT_RE = /\.(md|markdown|txt)$/i;
|
||||
const ZIP_MAX_ENTRIES = 2000;
|
||||
|
||||
/**
|
||||
* A skill folder name from free text: what the user typed, a frontmatter
|
||||
* `name:`, a folder or a file name. Spaces and other characters become `-`;
|
||||
* Vietnamese letters lose their marks. "" when nothing usable is left.
|
||||
*/
|
||||
function skillSlug(text) {
|
||||
return String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/đ/g, "d")
|
||||
.replace(/Đ/g, "D")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/^[-._]+|-+$/g, "")
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
function validSkillName(raw) {
|
||||
const name = skillSlug(raw);
|
||||
if (!SKILL_NAME_RE.test(name)) {
|
||||
throw fail("EBADNAME", "skill name: letters, digits, '.', '_' or '-' (it becomes the folder and the /command)");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** One uploaded path, made safe: posix, relative, no `..`, no secret, not too deep. */
|
||||
function cleanUploadPath(raw) {
|
||||
const p = String(raw || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^(\.\/)+/, "")
|
||||
.replace(/^\/+/, "");
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!p || /[\x00-\x1f]/.test(p)) throw fail("EBADPATH", "an uploaded file has no usable name");
|
||||
const parts = p.split("/").filter(Boolean);
|
||||
if (parts.some((x) => x === ".." || x === ".")) throw fail("EBADPATH", `not a path inside the skill: ${p}`);
|
||||
if (parts.length > SKILL_MAX_DEPTH + 2) throw fail("ETOOBIG", "the skill folder is nested too deep");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
/** A zip's (or a .skill's) regular files: sizes checked from its directory before inflating. */
|
||||
function unzipSkill(buf) {
|
||||
const AdmZip = require("adm-zip");
|
||||
let entries;
|
||||
try {
|
||||
entries = new AdmZip(buf).getEntries();
|
||||
} catch {
|
||||
throw fail("EBADZIP", "not a readable zip file");
|
||||
}
|
||||
if (entries.length > ZIP_MAX_ENTRIES) throw fail("ETOOBIG", "the zip has too many entries");
|
||||
let total = 0;
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory || JUNK_RE.test(e.entryName)) continue;
|
||||
const mode = (e.attr >>> 16) & 0o170000;
|
||||
if (mode === 0o120000) throw fail("ESYMLINK", `the zip holds a symlink: ${e.entryName}`);
|
||||
total += e.header.size;
|
||||
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
const data = e.getData();
|
||||
if (data.length !== e.header.size) throw fail("EBADZIP", `a damaged zip entry: ${e.entryName}`);
|
||||
out.push({ path: e.entryName, data });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload → skills. Every case a user may hand over:
|
||||
* - one Markdown/text file (any name) → that file is the skill's SKILL.md;
|
||||
* - a folder or several files with a SKILL.md (at the top or in one folder
|
||||
* under it) → that folder is the skill;
|
||||
* - a .zip / .skill archive → unpacked, then the same rules;
|
||||
* - several skill folders at once (each with its own SKILL.md) → one
|
||||
* artifact per skill, named by its folder.
|
||||
* `name` (the user's) names the skill when there is exactly one.
|
||||
*/
|
||||
function skillsFromUpload(uploads, { name, description }) {
|
||||
let files = [];
|
||||
for (const u of uploads) {
|
||||
const p = cleanUploadPath(u.path);
|
||||
if (JUNK_RE.test(p)) continue;
|
||||
if (ZIP_RE.test(p) && uploads.length === 1) {
|
||||
files.push(...unzipSkill(u.data).map((f) => ({ path: cleanUploadPath(f.path), data: f.data })));
|
||||
} else {
|
||||
files.push({ path: p, data: u.data });
|
||||
}
|
||||
}
|
||||
files = files.filter((f) => !JUNK_RE.test(f.path));
|
||||
if (!files.length) throw fail("ENOFILE", "nothing to add: the upload is empty");
|
||||
const seen = new Set();
|
||||
for (const f of files) {
|
||||
if (seen.has(f.path)) throw fail("EBADPATH", `the same path twice: ${f.path}`);
|
||||
seen.add(f.path);
|
||||
}
|
||||
|
||||
const isSkillMd = (p) => path.posix.basename(p).toLowerCase() === "skill.md";
|
||||
let roots = files.filter((f) => isSkillMd(f.path)).map((f) => path.posix.dirname(f.path));
|
||||
if (!roots.length) {
|
||||
if (files.length === 1 && TEXT_RE.test(files[0].path)) {
|
||||
const only = files[0];
|
||||
const stem = path.posix.basename(only.path).replace(/\.[^.]+$/, "");
|
||||
return [
|
||||
{
|
||||
fallbackName: frontmatterField(only.data.toString("utf8"), "name") || stem,
|
||||
files: [{ path: "SKILL.md", data: only.data }],
|
||||
},
|
||||
];
|
||||
}
|
||||
throw fail(
|
||||
"ENOSKILLMD",
|
||||
"no SKILL.md in the upload — add one, or upload a single Markdown file to use as SKILL.md"
|
||||
);
|
||||
}
|
||||
// A SKILL.md inside another skill's folder belongs to that skill.
|
||||
roots = [...new Set(roots)].sort((a, b) => a.length - b.length);
|
||||
const under = (dir, p) => dir === "." || p === dir || p.startsWith(`${dir}/`);
|
||||
roots = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
|
||||
return roots.map((root) => {
|
||||
const own = files
|
||||
.filter((f) => under(root, f.path))
|
||||
.map((f) => ({
|
||||
path: root === "." ? f.path : f.path.slice(root.length + 1),
|
||||
data: f.data,
|
||||
}))
|
||||
.map((f) => (f.path.toLowerCase() === "skill.md" ? { ...f, path: "SKILL.md" } : f));
|
||||
const md = own.find((f) => f.path === "SKILL.md");
|
||||
const folder = root === "." ? "" : path.posix.basename(root);
|
||||
return {
|
||||
fallbackName: frontmatterField(md.data.toString("utf8"), "name") || folder,
|
||||
files: own,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Check one skill's files like a folder on disk is checked. */
|
||||
function checkSkillFiles(files) {
|
||||
if (files.length > SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
|
||||
let total = 0;
|
||||
for (const f of files) {
|
||||
if (f.path.split("/").length > SKILL_MAX_DEPTH + 1) {
|
||||
throw fail("ETOOBIG", "the skill folder is nested too deep");
|
||||
}
|
||||
if (SECRET_NAME_RE.test(path.posix.basename(f.path))) {
|
||||
throw fail("ESECRET", `the skill holds a file that looks like a secret: ${f.path}`);
|
||||
}
|
||||
total += f.data.length;
|
||||
}
|
||||
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
}
|
||||
|
||||
/**
|
||||
* SKILL.md with the chosen `name:` and a `description:` in its frontmatter —
|
||||
* Claude Code reads both (the description decides when the skill is used).
|
||||
* A file with no frontmatter gets one; an existing one keeps every other line.
|
||||
*/
|
||||
function withFrontmatter(text, name, description) {
|
||||
const body = String(text).replace(/^\uFEFF/, "");
|
||||
const fm = /^---\r?\n([\s\S]*?)\r?\n---[^\n]*(\r?\n|$)/.exec(body);
|
||||
const desc = String(description || "").trim().replace(/\s+/g, " ");
|
||||
const quote = (v) => (/^[\w .,()/-]*$/.test(v) && !/^[\d.]+$/.test(v) ? v : JSON.stringify(v));
|
||||
if (!fm) {
|
||||
if (!desc) {
|
||||
throw fail(
|
||||
"ENODESCRIPTION",
|
||||
"a skill needs a description — Claude reads it to decide when to use the skill"
|
||||
);
|
||||
}
|
||||
return `---\nname: ${name}\ndescription: ${quote(desc)}\n---\n\n${body.replace(/^\s+/, "")}`;
|
||||
}
|
||||
let lines = fm[1].split(/\r?\n/);
|
||||
const has = (k) => lines.some((l) => new RegExp(`^${k}:`).test(l));
|
||||
lines = has("name")
|
||||
? lines.map((l) => (/^name:/.test(l) ? `name: ${name}` : l))
|
||||
: [`name: ${name}`, ...lines];
|
||||
if (desc) {
|
||||
lines = has("description")
|
||||
? lines.map((l) => (/^description:/.test(l) ? `description: ${quote(desc)}` : l))
|
||||
: [...lines, `description: ${quote(desc)}`];
|
||||
} else if (!frontmatterField(body, "description")) {
|
||||
throw fail(
|
||||
"ENODESCRIPTION",
|
||||
"a skill needs a description — Claude reads it to decide when to use the skill"
|
||||
);
|
||||
}
|
||||
return `---\n${lines.join("\n")}\n---\n${body.slice(fm[0].length)}`;
|
||||
}
|
||||
|
||||
/** Finish one skill: its name, its SKILL.md frontmatter, the checks, the snapshot. */
|
||||
function saveSkillFiles(req, { name, files, title, description }) {
|
||||
const md = files.find((f) => f.path === "SKILL.md");
|
||||
const text = md.data.toString("utf8");
|
||||
if (text.includes("\uFFFD")) throw fail("EBADSKILL", "SKILL.md is not a UTF-8 text file");
|
||||
const fixed = Buffer.from(withFrontmatter(text, name, description), "utf8");
|
||||
const all = [{ path: "SKILL.md", data: fixed }, ...files.filter((f) => f !== md)];
|
||||
checkSkillFiles(all);
|
||||
return storeSkill(req, { name, files: all, title, description });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add skills from uploaded files (multipart: `files` + their relative
|
||||
* `paths`). Answers every skill it made. Nothing is stored unless every
|
||||
* skill in the upload passes.
|
||||
*/
|
||||
function uploadSkills(req, { uploads, name, title, description }) {
|
||||
const found = skillsFromUpload(uploads || [], { name, description });
|
||||
const typed = typeof name === "string" && name.trim();
|
||||
const prepared = found.map((sk) => ({
|
||||
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
|
||||
files: sk.files,
|
||||
}));
|
||||
const dup = prepared.map((p) => p.name).find((n, i, a) => a.indexOf(n) !== i);
|
||||
if (dup) throw fail("EBADNAME", `two skills in the upload are both named ${dup}`);
|
||||
for (const p of prepared) {
|
||||
const md = p.files.find((f) => f.path === "SKILL.md");
|
||||
// Checked up front so a bad second skill stores nothing.
|
||||
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
|
||||
checkSkillFiles(p.files);
|
||||
}
|
||||
return prepared.map((p) =>
|
||||
saveSkillFiles(req, {
|
||||
name: p.name,
|
||||
files: p.files,
|
||||
title: found.length === 1 ? title : "",
|
||||
description: found.length === 1 ? description : "",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** A skill written in the dialog: a name, a description and its instructions. */
|
||||
function writeSkill(req, { name, description, content, title }) {
|
||||
const n = validSkillName(name);
|
||||
const text = typeof content === "string" ? content : "";
|
||||
if (!text.trim()) throw fail("EBADSKILL", "write the skill's instructions");
|
||||
if (Buffer.byteLength(text) > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
return saveSkillFiles(req, {
|
||||
name: n,
|
||||
files: [{ path: "SKILL.md", data: Buffer.from(text, "utf8") }],
|
||||
title,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
// ── plugin ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A marketplace a plugin may be installed from: `owner/repo` or a remote URL. */
|
||||
@@ -550,9 +857,164 @@ function validMarket(market) {
|
||||
}
|
||||
}
|
||||
|
||||
const MARKET_JSON_MAX = 1024 * 1024;
|
||||
const MARKET_TIMEOUT_MS = 45_000;
|
||||
let inspectImpl = null; // tests: (market) => marketplace.json text
|
||||
|
||||
/**
|
||||
* What a user pasted, made into a marketplace source Claude Code takes:
|
||||
* `owner/repo`, a git URL (a forge's web link `…/src/branch/main`,
|
||||
* `…/tree/main` or a trailing slash trimmed off), or an https link to a
|
||||
* `marketplace.json`.
|
||||
*/
|
||||
function normalizeMarket(raw) {
|
||||
let m = typeof raw === "string" ? raw.trim() : "";
|
||||
const gh = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(m);
|
||||
if (gh && GITHUB_REPO_RE.test(gh[1])) return gh[1];
|
||||
if (/^https:\/\//.test(m) && !/\.json$/i.test(m)) {
|
||||
m = m.replace(/\/(?:src|tree|blob)\/.*$/, "").replace(/\/+$/, "");
|
||||
}
|
||||
return validMarket(m);
|
||||
}
|
||||
|
||||
/** An https URL git can clone for `market` (owner/repo = GitHub), or null (ssh). */
|
||||
function cloneUrl(market) {
|
||||
if (GITHUB_REPO_RE.test(market)) return `https://github.com/${market}.git`;
|
||||
return market.startsWith("https://") ? market : null;
|
||||
}
|
||||
|
||||
/** The text of a marketplace's `.claude-plugin/marketplace.json`. */
|
||||
async function fetchMarketJson(market) {
|
||||
if (inspectImpl) return inspectImpl(market);
|
||||
if (/^https:\/\/.*\.json$/i.test(market)) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(market, { signal: AbortSignal.timeout(MARKET_TIMEOUT_MS), redirect: "follow" });
|
||||
} catch (e) {
|
||||
throw fail("EMARKETFETCH", `could not read ${market}: ${e.message}`);
|
||||
}
|
||||
if (!res.url.startsWith("https://")) throw fail("EMARKETFETCH", "the link redirected off https");
|
||||
if (!res.ok) throw fail("EMARKETFETCH", `${market} answered ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (text.length > MARKET_JSON_MAX) throw fail("ETOOBIG", "marketplace.json is larger than 1 MB");
|
||||
return text;
|
||||
}
|
||||
const url = cloneUrl(market);
|
||||
if (!url) {
|
||||
throw fail(
|
||||
"ESSHMARKET",
|
||||
"an ssh link cannot be read from here — paste the https link to list its plugins, or type name@marketplace by hand"
|
||||
);
|
||||
}
|
||||
const { git } = require("./worktree");
|
||||
const tmp = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "agenthub-market-"));
|
||||
// No login is ever sent: the machine's credential helpers are switched off,
|
||||
// so a URL a browser names never receives a token stored for another use.
|
||||
const env = { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" };
|
||||
const safe = [
|
||||
"-c", "credential.helper=",
|
||||
"-c", "protocol.allow=never",
|
||||
"-c", "protocol.https.allow=always",
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
];
|
||||
try {
|
||||
try {
|
||||
await git(tmp, [...safe, "clone", "--depth", "1", "--filter=blob:none", "--no-checkout", "--quiet", "--", url, "repo"], {
|
||||
literal: true,
|
||||
timeout: MARKET_TIMEOUT_MS,
|
||||
env,
|
||||
});
|
||||
} catch (e) {
|
||||
const why = String(e?.git?.stderr || e?.stderr || e?.message || "");
|
||||
if (/terminal prompts disabled|could not read Username|Authentication failed|403|401/i.test(why)) {
|
||||
throw fail(
|
||||
"ENEEDLOGIN",
|
||||
"the repository does not exist, or it needs a login to read — check the link, or type the plugin as name@marketplace by hand (installing uses each user's own git login)"
|
||||
);
|
||||
}
|
||||
if (/not found|does not exist|404/i.test(why)) throw fail("EMARKETFETCH", "no repository at that link");
|
||||
throw fail("EMARKETFETCH", `could not read the repository: ${why.trim().split("\n").pop()}`);
|
||||
}
|
||||
const repo = path.join(tmp, "repo");
|
||||
const show = (p) =>
|
||||
git(repo, ["show", `HEAD:${p}`], { literal: true, timeout: MARKET_TIMEOUT_MS, env, maxBuffer: MARKET_JSON_MAX }).then(
|
||||
(r) => (typeof r === "string" ? r : r.stdout),
|
||||
() => null
|
||||
);
|
||||
const text = await show(".claude-plugin/marketplace.json");
|
||||
if (text != null) return text;
|
||||
if ((await show(".claude-plugin/plugin.json")) != null) {
|
||||
throw fail(
|
||||
"ENOTMARKET",
|
||||
"this repository is one plugin, not a marketplace — Claude Code installs plugins from a marketplace (a repo with .claude-plugin/marketplace.json). Add one to it, or share its skills as Skill artifacts"
|
||||
);
|
||||
}
|
||||
throw fail("ENOTMARKET", "no .claude-plugin/marketplace.json in this repository");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a marketplace and list its plugins:
|
||||
* `{ market, name, description, plugins: [{ name, key, description, version, category }] }`.
|
||||
*/
|
||||
async function inspectMarket(raw) {
|
||||
const market = normalizeMarket(raw);
|
||||
const text = await fetchMarketJson(market);
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw fail("EBADMARKET", "marketplace.json is not valid JSON");
|
||||
}
|
||||
const name = typeof json?.name === "string" ? json.name.trim() : "";
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
|
||||
throw fail("EBADMARKET", "marketplace.json has no usable name");
|
||||
}
|
||||
const str = (v, n = DESCRIPTION_MAX) => (typeof v === "string" ? v.trim().slice(0, n) : "");
|
||||
const plugins = (Array.isArray(json.plugins) ? json.plugins : [])
|
||||
.filter((p) => p && typeof p.name === "string" && PLUGIN_RE.test(`${p.name.trim()}@${name}`))
|
||||
.slice(0, 500)
|
||||
.map((p) => ({
|
||||
name: p.name.trim(),
|
||||
key: `${p.name.trim()}@${name}`,
|
||||
description: str(p.description),
|
||||
version: str(p.version, 50),
|
||||
category: str(p.category, 50),
|
||||
}));
|
||||
return { market, name, description: str(json.description || json.metadata?.description), plugins };
|
||||
}
|
||||
|
||||
function __setInspectImpl(fn) {
|
||||
inspectImpl = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a plugin by `name@marketplace` and its marketplace source. The same
|
||||
* plugin added again by its owner updates that row instead of a duplicate.
|
||||
*/
|
||||
function addPlugin(req, { key, market, title, description }) {
|
||||
const k = typeof key === "string" ? key.trim() : "";
|
||||
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
const ownerId = req?.user ? req.user.id : null;
|
||||
const src = normalizeMarket(market);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'plugin' AND source = ? AND owner_id IS ?").get(
|
||||
k,
|
||||
ownerId
|
||||
);
|
||||
if (same) {
|
||||
sql(
|
||||
`UPDATE artifacts SET market = ?, title = ?, description = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(
|
||||
src,
|
||||
cleanTitle(title, same.title),
|
||||
cleanDescription(description) || same.description,
|
||||
same.id
|
||||
);
|
||||
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
|
||||
}
|
||||
return view(
|
||||
insert({
|
||||
kind: "plugin",
|
||||
@@ -560,7 +1022,7 @@ function addPlugin(req, { key, market, title, description }) {
|
||||
description: cleanDescription(description),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
source: k,
|
||||
market: validMarket(market),
|
||||
market: src,
|
||||
}),
|
||||
req
|
||||
);
|
||||
@@ -595,6 +1057,16 @@ function update(req, id, body = {}) {
|
||||
sets.push("description = ?");
|
||||
params.push(cleanDescription(body.description));
|
||||
}
|
||||
if (row.kind === "plugin" && "source" in body) {
|
||||
const k = typeof body.source === "string" ? body.source.trim() : "";
|
||||
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
sets.push("source = ?");
|
||||
params.push(k);
|
||||
}
|
||||
if (row.kind === "plugin" && "market" in body) {
|
||||
sets.push("market = ?");
|
||||
params.push(normalizeMarket(body.market));
|
||||
}
|
||||
let shareChange = null;
|
||||
if ("shared" in body) {
|
||||
if (typeof body.shared !== "boolean") throw fail("EBADFIELD", "shared: true or false");
|
||||
@@ -771,6 +1243,13 @@ module.exports = {
|
||||
pinFile,
|
||||
availableSkills,
|
||||
addSkill,
|
||||
uploadSkills,
|
||||
writeSkill,
|
||||
uploadFile,
|
||||
skillSlug,
|
||||
withFrontmatter,
|
||||
normalizeMarket,
|
||||
inspectMarket,
|
||||
addPlugin,
|
||||
update,
|
||||
remove,
|
||||
@@ -779,4 +1258,5 @@ module.exports = {
|
||||
installsEnabled,
|
||||
setInstallsEnabled,
|
||||
__setExecImpl,
|
||||
__setInspectImpl,
|
||||
};
|
||||
|
||||
@@ -142,6 +142,8 @@ const publicUser = (u) =>
|
||||
disabled: Boolean(u.disabled),
|
||||
displayName: u.display_name ?? null,
|
||||
email: u.email ?? null,
|
||||
// When the avatar was last set (null = none): the client's cache-buster.
|
||||
avatarAt: u.avatar_at ?? null,
|
||||
createdAt: u.created_at ?? null,
|
||||
};
|
||||
|
||||
@@ -349,6 +351,7 @@ function deleteUser(username) {
|
||||
const lanesLib = require("./lanes");
|
||||
for (const lane of lanes) lanesLib.deleteLane(lane.id);
|
||||
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
|
||||
require("./avatars").removeAvatar(u.id);
|
||||
try {
|
||||
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file avatars.js
|
||||
* @description A server-mode account's own avatar (Settings → Account, shown
|
||||
* in the sidebar). One raster image per account, stored as
|
||||
* `<data dir>/avatars/u<id>.<png|jpg|gif|webp>` — a name made from the
|
||||
* account id and the type its magic bytes say, never from the request — and
|
||||
* `users.avatar_at` records when it was set, so the client can version the
|
||||
* URL. Raster only: an SVG served from the dashboard's origin runs script.
|
||||
* At most `MAX_AVATAR_BYTES`; the serving route adds `nosniff` and a
|
||||
* `sandbox` CSP like every other user-supplied file.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const { sniffImage } = require("./pets");
|
||||
|
||||
const MAX_AVATAR_BYTES = 1024 * 1024;
|
||||
const EXTS = ["png", "jpg", "gif", "webp"];
|
||||
const TYPES = { png: "image/png", jpg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
|
||||
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
const avatarsDir = () => path.join(getDataDir(), "avatars");
|
||||
|
||||
function fileName(userId, ext) {
|
||||
const id = Number(userId);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) throw fail("EBADFIELD", "bad account id");
|
||||
return path.join(avatarsDir(), `u${id}.${ext}`);
|
||||
}
|
||||
|
||||
const db = () => require("../db").db;
|
||||
|
||||
/** Deletes every stored avatar file of the account and clears `avatar_at`. */
|
||||
function removeAvatar(userId) {
|
||||
for (const ext of EXTS) fs.rmSync(fileName(userId, ext), { force: true });
|
||||
db().prepare("UPDATE users SET avatar_at = NULL WHERE id = ?").run(Number(userId));
|
||||
}
|
||||
|
||||
/** Stores `buf` as the account's avatar (EBADIMAGE / ETOOBIG); returns `avatar_at`. */
|
||||
function setAvatar(userId, buf) {
|
||||
if (!Buffer.isBuffer(buf) || buf.length === 0) throw fail("EBADIMAGE", "no image sent");
|
||||
if (buf.length > MAX_AVATAR_BYTES) throw fail("ETOOBIG", "the image is too large");
|
||||
const ext = sniffImage(buf);
|
||||
if (!ext) throw fail("EBADIMAGE", "only PNG, JPEG, GIF or WebP images");
|
||||
const target = fileName(userId, ext);
|
||||
fs.mkdirSync(avatarsDir(), { recursive: true, mode: 0o700 });
|
||||
// Written beside, then renamed: a reader never sees half an image.
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, buf, { mode: 0o600 });
|
||||
fs.renameSync(tmp, target);
|
||||
for (const other of EXTS) {
|
||||
if (other !== ext) fs.rmSync(fileName(userId, other), { force: true });
|
||||
}
|
||||
const at = new Date().toISOString();
|
||||
db().prepare("UPDATE users SET avatar_at = ? WHERE id = ?").run(at, Number(userId));
|
||||
return at;
|
||||
}
|
||||
|
||||
/** The account's avatar file and its type, or null when it has none. */
|
||||
function avatarFile(userId) {
|
||||
for (const ext of EXTS) {
|
||||
const file = fileName(userId, ext);
|
||||
const st = fs.lstatSync(file, { throwIfNoEntry: false });
|
||||
if (st?.isFile()) return { path: file, type: TYPES[ext] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { MAX_AVATAR_BYTES, setAvatar, removeAvatar, avatarFile, avatarsDir };
|
||||
+18
-53
@@ -15,9 +15,9 @@
|
||||
* 1. an account whose last quota reading is ≥ `BLOCK_PERCENT` in either
|
||||
* window goes last (still used when every account is that full);
|
||||
* 2. fewest live panes (spreads concurrent sessions);
|
||||
* 3. least $ spent in the last 5 hours, then in the last 7 days — summed from
|
||||
* `token_usage_hourly` for the sessions its runs (`dashboard_runs.account`)
|
||||
* started.
|
||||
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
|
||||
* sessions its runs (`dashboard_runs.account`) held, over the hours they
|
||||
* held them (pool-usage.js).
|
||||
* The quota is read with the account's own token in the background, at most
|
||||
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
|
||||
* reading, and ranking falls back to spend.
|
||||
@@ -375,47 +375,8 @@ function quotaPercent(name) {
|
||||
|
||||
// ── What AgentHub recorded per account ─────────────────────────────────────
|
||||
|
||||
const USAGE_COLS = [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_1h_tokens",
|
||||
"web_search_requests",
|
||||
"web_fetch_requests",
|
||||
"code_execution_requests",
|
||||
"long_input_tokens",
|
||||
"long_output_tokens",
|
||||
"long_cache_read_tokens",
|
||||
"long_cache_write_tokens",
|
||||
"long_cache_write_1h_tokens",
|
||||
];
|
||||
|
||||
/**
|
||||
* $ spent on `account` since `sinceHour` (`YYYY-MM-DDTHH`, UTC): the hourly
|
||||
* usage of every session one of its runs started, from that run's start hour.
|
||||
* ponytail: a session later resumed on ANOTHER account keeps counting here
|
||||
* too; split by run end time if that ever skews a ranking.
|
||||
*/
|
||||
function spendSince(account, sinceHour) {
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost } = require("../routes/pricing");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`WITH s AS (
|
||||
SELECT session_id, MIN(substr(started_at, 1, 13)) AS since
|
||||
FROM dashboard_runs
|
||||
WHERE tool = 'claude' AND account = ? AND session_id IS NOT NULL
|
||||
GROUP BY session_id)
|
||||
SELECT h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
||||
FROM s JOIN token_usage_hourly h
|
||||
ON h.session_id = s.session_id AND h.hour >= s.since AND h.hour >= ?
|
||||
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
|
||||
)
|
||||
.all(account, sinceHour);
|
||||
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
|
||||
}
|
||||
/** $ and tokens of the last 5 h / 7 d (pool-usage.js). */
|
||||
const usageOf = (account) => require("./pool-usage").accountUsage("claude", account);
|
||||
|
||||
function livePanes(account) {
|
||||
const { db } = require("../db");
|
||||
@@ -426,8 +387,6 @@ function livePanes(account) {
|
||||
.get(account).n;
|
||||
}
|
||||
|
||||
const hourAgo = (hours) => new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13);
|
||||
|
||||
/** Every account with what the balancer sees — never a token. */
|
||||
function poolStatus() {
|
||||
return allAccounts().map((a) => {
|
||||
@@ -442,8 +401,7 @@ function poolStatus() {
|
||||
expiresAt: expiresAt(a),
|
||||
expired: expired(a),
|
||||
livePanes: livePanes(a.name),
|
||||
spend5h: spendSince(a.name, hourAgo(5)),
|
||||
spend7d: spendSince(a.name, hourAgo(24 * 7)),
|
||||
...usageOf(a.name),
|
||||
quota: q
|
||||
? {
|
||||
fiveHour: q.fiveHour || null,
|
||||
@@ -481,8 +439,11 @@ function pick(member) {
|
||||
let usable = allAccounts().filter((a) => !expired(a));
|
||||
if (usable.length === 0) return null;
|
||||
if (!member) {
|
||||
// A host pane's home holds the host's own login.
|
||||
usable = usable.filter((a) => a.kind === "token");
|
||||
// A host pane's home holds the host's own login — a pool account only when
|
||||
// it was moved in from there (`hostHolds`) and is still the pair on disk:
|
||||
// the pane then runs on it as it is, and its usage is that account's.
|
||||
const disk = homeCreds(HOST_ACCT);
|
||||
usable = usable.filter((a) => a.kind === "token" || ownsHome(a, HOST, disk));
|
||||
} else {
|
||||
const disk = homeCreds(member);
|
||||
const placed = disk && usable.find((a) => ownsHome(a, member.name, disk));
|
||||
@@ -501,13 +462,14 @@ function pick(member) {
|
||||
const ranked = usable
|
||||
.map((a) => {
|
||||
const percent = quotaPercent(a.name);
|
||||
const u = usageOf(a.name);
|
||||
return {
|
||||
a,
|
||||
key: [
|
||||
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
|
||||
livePanes(a.name),
|
||||
spendSince(a.name, hourAgo(5)),
|
||||
spendSince(a.name, hourAgo(24 * 7)),
|
||||
u.spend5h,
|
||||
u.spend7d,
|
||||
],
|
||||
};
|
||||
})
|
||||
@@ -524,7 +486,8 @@ function pick(member) {
|
||||
* The account a new Claude pane should run on — `{name, kind, token?}` — or
|
||||
* null when the pool has nothing this pane may use. Synchronous (a spawn
|
||||
* cannot wait on the network): it ranks on the cached quota and starts a
|
||||
* background re-read. A `login` is only ever offered for a member.
|
||||
* background re-read. A `login` is offered for a member, and for a host pane
|
||||
* only when it is the host's own login (`hostHolds`), which stays as it is.
|
||||
*/
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
@@ -545,6 +508,8 @@ function forSpawn(member = null) {
|
||||
if (a.kind === "token") {
|
||||
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
|
||||
}
|
||||
// The host's own login is already in place; the server keeps it fresh.
|
||||
if (!member) return { name: a.name, env: {}, place: () => {} };
|
||||
return {
|
||||
name: a.name,
|
||||
env: {},
|
||||
|
||||
@@ -155,29 +155,12 @@ function livePanes(account) {
|
||||
}
|
||||
|
||||
/**
|
||||
* $ of the Codex sessions this account's runs started since `hours` ago.
|
||||
* $ and tokens of the last 5 h / 7 d (pool-usage.js).
|
||||
* ponytail: Codex usage has no hourly table, so a session counts whole once
|
||||
* its run started in the window; add hourly rows if that skews a ranking.
|
||||
*/
|
||||
function spendSince(account, hours) {
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost } = require("../routes/pricing");
|
||||
const since = new Date(Date.now() - hours * 3600e3).toISOString();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT model, speed, inference_geo, service_tier,
|
||||
SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
|
||||
SUM(cache_read_tokens) AS cache_read_tokens,
|
||||
SUM(cache_write_tokens) AS cache_write_tokens
|
||||
FROM token_usage
|
||||
WHERE session_id IN (SELECT session_id FROM dashboard_runs
|
||||
WHERE tool = 'codex' AND account = ?
|
||||
AND session_id IS NOT NULL AND started_at >= ?)
|
||||
GROUP BY model, speed, inference_geo, service_tier`
|
||||
)
|
||||
.all(account, since);
|
||||
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
|
||||
}
|
||||
const usageOf = (account) =>
|
||||
require("./pool-usage").accountUsage("codex", account, { wholeSession: true });
|
||||
|
||||
// ── Quota, from the account's own rollouts ─────────────────────────────────
|
||||
|
||||
@@ -262,8 +245,7 @@ function poolStatus() {
|
||||
kind: a.kind,
|
||||
addedAt: a.addedAt,
|
||||
livePanes: livePanes(a.name),
|
||||
spend5h: spendSince(a.name, 5),
|
||||
spend7d: spendSince(a.name, 24 * 7),
|
||||
...usageOf(a.name),
|
||||
quota: quotas().get(a.name) || null,
|
||||
quotaPercent: percent,
|
||||
full: percent != null && percent >= BLOCK_PERCENT,
|
||||
@@ -374,13 +356,14 @@ function pick(member) {
|
||||
const ranked = usable
|
||||
.map((a) => {
|
||||
const percent = quotaPercent(a.name);
|
||||
const u = usageOf(a.name);
|
||||
return {
|
||||
a,
|
||||
key: [
|
||||
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
|
||||
livePanes(a.name),
|
||||
spendSince(a.name, 5),
|
||||
spendSince(a.name, 24 * 7),
|
||||
u.spend5h,
|
||||
u.spend7d,
|
||||
],
|
||||
};
|
||||
})
|
||||
|
||||
+10
-5
@@ -736,12 +736,17 @@ function inferredDisplayStage(lane) {
|
||||
return lane.detected_stage;
|
||||
}
|
||||
|
||||
/** The owner's username and display name — the staff sidebar groups
|
||||
* workspaces by owner and shows the display name when one is set. */
|
||||
/** The owner's username, display name and avatar version — the staff
|
||||
* sidebar groups workspaces by owner and shows the display name and picture
|
||||
* (`GET /api/users/:id/avatar`) when one is set. */
|
||||
function ownerNames(id) {
|
||||
if (id == null) return { owner_name: null, owner_display_name: null };
|
||||
const u = db.prepare("SELECT username, display_name FROM users WHERE id = ?").get(id);
|
||||
return { owner_name: u?.username ?? null, owner_display_name: u?.display_name ?? null };
|
||||
if (id == null) return { owner_name: null, owner_display_name: null, owner_avatar_at: null };
|
||||
const u = db.prepare("SELECT username, display_name, avatar_at FROM users WHERE id = ?").get(id);
|
||||
return {
|
||||
owner_name: u?.username ?? null,
|
||||
owner_display_name: u?.display_name ?? null,
|
||||
owner_avatar_at: u?.avatar_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function lanePayload(lane, ageSec = null) {
|
||||
|
||||
@@ -333,5 +333,6 @@ module.exports = {
|
||||
removePet,
|
||||
spriteFile,
|
||||
webpSize,
|
||||
sniffImage,
|
||||
__setFetch,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file pool-usage.js
|
||||
* @description $ and tokens one account-pool account spent in the last 5 hours
|
||||
* and 7 days, for the balancer and `GET /api/accounts` (claude-pool.js,
|
||||
* codex-pool.js). Tokens are booked to hours by `hourlyTokenRows` — the same
|
||||
* booking as the daily cost split, so sub-agent buckets and pre-compaction
|
||||
* baselines count — and priced by `calculateCost`, never a second formula.
|
||||
* A Claude session counts only for the hours one of THIS account's runs held
|
||||
* it (start hour to end hour), so a session resumed on another account is
|
||||
* not booked twice; Codex has no hourly rows, so its session counts whole
|
||||
* once a run of the account started in the window, as before.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const WINDOWS = { "5h": 5, "7d": 24 * 7 };
|
||||
const hourAgo = (hours, now) => new Date(now - hours * 3600e3).toISOString().slice(0, 13);
|
||||
const tokensOf = (r) =>
|
||||
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
|
||||
|
||||
/**
|
||||
* `{spend5h, spend7d, tokens5h, tokens7d}` of `account` on `tool`. With
|
||||
* `wholeSession`, a session's every row counts in a window its run started in.
|
||||
*/
|
||||
function accountUsage(tool, account, { wholeSession = false, now = Date.now() } = {}) {
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
|
||||
const since7d = new Date(now - WINDOWS["7d"] * 3600e3).toISOString();
|
||||
const runs = db
|
||||
.prepare(
|
||||
`SELECT session_id, substr(started_at, 1, 13) AS start_hour, started_at,
|
||||
substr(COALESCE(ended_at, '9999'), 1, 13) AS end_hour
|
||||
FROM dashboard_runs
|
||||
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
|
||||
AND COALESCE(ended_at, '9999') >= ?`
|
||||
)
|
||||
.all(tool, account, since7d);
|
||||
const out = { spend5h: 0, spend7d: 0, tokens5h: 0, tokens7d: 0 };
|
||||
if (runs.length === 0) return out;
|
||||
const runsOf = new Map();
|
||||
for (const r of runs) runsOf.set(r.session_id, [...(runsOf.get(r.session_id) || []), r]);
|
||||
const rows = hourlyTokenRows(
|
||||
`WHERE s.id IN (SELECT session_id FROM dashboard_runs
|
||||
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
|
||||
AND COALESCE(ended_at, '9999') >= ?)`,
|
||||
[tool, account, since7d]
|
||||
);
|
||||
const rules = stmts.listPricing.all();
|
||||
for (const [label, hours] of Object.entries(WINDOWS)) {
|
||||
const sinceHour = hourAgo(hours, now);
|
||||
const sinceIso = new Date(now - hours * 3600e3).toISOString();
|
||||
const held = rows.filter((row) =>
|
||||
(runsOf.get(row.session_id) || []).some((run) =>
|
||||
wholeSession
|
||||
? run.started_at >= sinceIso
|
||||
: row.date >= sinceHour && row.date >= run.start_hour && row.date <= run.end_hour
|
||||
)
|
||||
);
|
||||
out[`spend${label}`] = held.length ? calculateCost(held, rules).total_cost : 0;
|
||||
out[`tokens${label}`] = Math.round(held.reduce((n, r) => n + tokensOf(r), 0));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { accountUsage };
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @file user-storage.js
|
||||
* @description How much disk each server-mode user's HOME folder takes
|
||||
* (`homes.homeOf`: their own Linux home under S3, their folder under
|
||||
* AGENTHUB_USERS_ROOT otherwise) — the Users page's Storage column
|
||||
* (`GET /api/users/storage`). A home is walked in a worker thread, never on
|
||||
* the event loop (a member decides how many files their home holds), one home
|
||||
* at a time, under a time limit (`AGENTHUB_STORAGE_TIMEOUT_MS`, default 120 s
|
||||
* — past it the number is a lower bound, `partial`). The walk is `du`-like and
|
||||
* read-only: `lstat` only (no file is opened, a symlink is never followed),
|
||||
* stays on the home's file system, counts allocated blocks (a sparse file
|
||||
* costs what it really uses) and a hard-linked inode once. Results are cached
|
||||
* in memory for `FRESH_MS`; a read returns the cache at once and measures the
|
||||
* stale homes in the background. A user whose "home" is only the host
|
||||
* account's own (an admin with no folder of their own) is not measured.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const FRESH_MS = 15 * 60_000;
|
||||
|
||||
/** Walk `root`: `{ bytes, files, dirs, partial }`. Runs in the worker. */
|
||||
function walk(root, deadline) {
|
||||
const out = { bytes: 0, files: 0, dirs: 0, partial: false };
|
||||
let top;
|
||||
try {
|
||||
top = fs.lstatSync(root, { bigint: false });
|
||||
} catch {
|
||||
return { ...out, partial: true };
|
||||
}
|
||||
if (!top.isDirectory()) return out;
|
||||
const seen = new Set(); // dev:ino of hard-linked files already counted
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
if (Date.now() > deadline) {
|
||||
out.partial = true;
|
||||
break;
|
||||
}
|
||||
const dir = stack.pop();
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(dir);
|
||||
} catch {
|
||||
out.partial = true; // unreadable (no CAP_DAC_READ_SEARCH, a race): a lower bound
|
||||
continue;
|
||||
}
|
||||
for (const name of names) {
|
||||
const p = path.join(dir, name);
|
||||
let st;
|
||||
try {
|
||||
st = fs.lstatSync(p);
|
||||
} catch {
|
||||
continue; // gone meanwhile
|
||||
}
|
||||
if (st.nlink > 1 && !st.isDirectory()) {
|
||||
const key = `${st.dev}:${st.ino}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
}
|
||||
out.bytes += st.blocks * 512;
|
||||
if (st.isDirectory()) {
|
||||
out.dirs += 1;
|
||||
if (st.dev === top.dev) stack.push(p); // one file system, like du -x
|
||||
} else {
|
||||
out.files += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.bytes += top.blocks * 512;
|
||||
return out;
|
||||
}
|
||||
|
||||
if (!isMainThread && workerData?.agenthubStorageWorker) {
|
||||
const { dir, timeoutMs } = workerData;
|
||||
parentPort.postMessage(walk(dir, Date.now() + timeoutMs));
|
||||
}
|
||||
|
||||
function timeoutMs() {
|
||||
return Number(process.env.AGENTHUB_STORAGE_TIMEOUT_MS) || 120_000;
|
||||
}
|
||||
|
||||
/** Measure one folder in a fresh worker; never rejects. */
|
||||
function measure(dir) {
|
||||
return new Promise((resolve) => {
|
||||
let w;
|
||||
const fail = () => resolve({ bytes: 0, files: 0, dirs: 0, partial: true });
|
||||
try {
|
||||
w = new Worker(__filename, {
|
||||
workerData: { agenthubStorageWorker: true, dir, timeoutMs: timeoutMs() },
|
||||
});
|
||||
} catch {
|
||||
return fail();
|
||||
}
|
||||
// The walk stops itself at the deadline; this only catches a worker stuck
|
||||
// inside one syscall (a hung network mount).
|
||||
const kill = setTimeout(() => {
|
||||
w.terminate().catch(() => {});
|
||||
fail();
|
||||
}, timeoutMs() + 30_000);
|
||||
kill.unref?.();
|
||||
w.once("message", (r) => {
|
||||
clearTimeout(kill);
|
||||
resolve(r);
|
||||
w.terminate().catch(() => {});
|
||||
});
|
||||
w.once("error", () => {
|
||||
clearTimeout(kill);
|
||||
fail();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const cache = new Map(); // dir → { bytes, files, dirs, partial, measuredAt }
|
||||
const queue = []; // dirs waiting to be measured
|
||||
let running = null; // dir being measured
|
||||
|
||||
function pump() {
|
||||
if (running || !queue.length) return;
|
||||
running = queue.shift();
|
||||
const dir = running;
|
||||
measure(dir).then((r) => {
|
||||
cache.set(dir, { ...r, measuredAt: new Date().toISOString() });
|
||||
running = null;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
function enqueue(dir) {
|
||||
if (running === dir || queue.includes(dir)) return;
|
||||
queue.push(dir);
|
||||
pump();
|
||||
}
|
||||
|
||||
/**
|
||||
* The storage of each of `users` (rows from `auth.listUsers()`):
|
||||
* `{ storage: { [id]: { bytes, files, partial, measuredAt } | null }, measuring }`.
|
||||
* `null` = nothing to measure (no home of their own) or not measured yet —
|
||||
* `measuring` says whether more is coming. `refresh` re-measures every home.
|
||||
*/
|
||||
function storageFor(users, { refresh = false } = {}) {
|
||||
const os = require("node:os");
|
||||
const { homeOf } = require("./homes");
|
||||
const hostHome = path.resolve(os.homedir());
|
||||
const storage = {};
|
||||
let measuring = false;
|
||||
for (const u of users) {
|
||||
let dir = null;
|
||||
try {
|
||||
dir = homeOf(u);
|
||||
} catch {
|
||||
dir = null;
|
||||
}
|
||||
if (!dir || path.resolve(dir) === hostHome) {
|
||||
storage[u.id] = null;
|
||||
continue;
|
||||
}
|
||||
dir = path.resolve(dir);
|
||||
const hit = cache.get(dir);
|
||||
const stale = !hit || refresh || Date.now() - Date.parse(hit.measuredAt) > FRESH_MS;
|
||||
if (stale) enqueue(dir);
|
||||
if (running === dir || queue.includes(dir)) measuring = true;
|
||||
storage[u.id] = hit
|
||||
? { bytes: hit.bytes, files: hit.files, partial: hit.partial, measuredAt: hit.measuredAt }
|
||||
: null;
|
||||
}
|
||||
return { storage, measuring };
|
||||
}
|
||||
|
||||
module.exports = { storageFor, walk, _cache: cache };
|
||||
@@ -11,7 +11,9 @@
|
||||
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
|
||||
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
|
||||
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
||||
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
||||
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -44,6 +46,11 @@ const schemas = {
|
||||
disabled: { type: "boolean" },
|
||||
displayName: { type: "string", nullable: true },
|
||||
email: { type: "string", nullable: true },
|
||||
avatarAt: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "When the avatar was last set (null = none)",
|
||||
},
|
||||
createdAt: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
@@ -283,6 +290,45 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/avatar": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "getMyAvatar",
|
||||
summary: "The account's own picture (nosniff, sandbox CSP)",
|
||||
responses: {
|
||||
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
|
||||
401: err("EAUTH"),
|
||||
404: err("ENOAVATAR"),
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Auth"],
|
||||
operationId: "setMyAvatar",
|
||||
summary:
|
||||
"Set the account's picture from the raw image bytes (PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Set", content: accountBody },
|
||||
400: err("EBADIMAGE"),
|
||||
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||
403: err("ELOCALMODE"),
|
||||
413: err("ETOOBIG"),
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
operationId: "removeMyAvatar",
|
||||
summary: "Remove the account's picture",
|
||||
responses: {
|
||||
200: { description: "Removed", content: accountBody },
|
||||
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||
403: err("ELOCALMODE"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
@@ -363,6 +409,19 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/avatar": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "getUserAvatar",
|
||||
summary: "One account's picture, for the sidebar's owner groups (admin/analyst)",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
|
||||
403: err("EADMIN — a developer"),
|
||||
404: err("ENOAVATAR"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
@@ -493,6 +552,8 @@ const paths = {
|
||||
livePanes: { type: "integer" },
|
||||
spend5h: { type: "number" },
|
||||
spend7d: { type: "number" },
|
||||
tokens5h: { type: "integer" },
|
||||
tokens7d: { type: "integer" },
|
||||
quota: { type: "object", nullable: true },
|
||||
quotaPercent: { type: "number", nullable: true },
|
||||
full: { type: "boolean" },
|
||||
@@ -512,6 +573,8 @@ const paths = {
|
||||
livePanes: { type: "integer" },
|
||||
spend5h: { type: "number" },
|
||||
spend7d: { type: "number" },
|
||||
tokens5h: { type: "integer" },
|
||||
tokens7d: { type: "integer" },
|
||||
quota: { type: "object", nullable: true },
|
||||
quotaPercent: { type: "number", nullable: true },
|
||||
full: { type: "boolean" },
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file artifacts.js (routes)
|
||||
* @description `/api/artifacts` — the Artifacts hub (server/lib/artifacts.js):
|
||||
* list, add (a claude.ai page, a pinned workspace file, a skill, a plugin),
|
||||
* list, add (a claude.ai page, a pinned workspace file or an uploaded one, a
|
||||
* skill — picked, uploaded (file, folder, zip) or written —, a plugin, after
|
||||
* reading its marketplace's plugin list from a link),
|
||||
* rename/share/unshare, delete, read a snapshot file (preview/download, under
|
||||
* a `sandbox` CSP with `nosniff`, so nothing shared runs on the dashboard's
|
||||
* origin), and install a shared skill or plugin into the caller's own
|
||||
@@ -32,6 +34,8 @@ const STATUS = {
|
||||
EUPGRADE: 409,
|
||||
ENOTSHARED: 409,
|
||||
EPLUGIN: 502,
|
||||
EMARKETFETCH: 502,
|
||||
ENEEDLOGIN: 502,
|
||||
ETOOBIG: 413,
|
||||
};
|
||||
|
||||
@@ -152,6 +156,93 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
// Read a marketplace (a git link, owner/repo, or a marketplace.json link)
|
||||
// and list its plugins to pick from. A POST: it reaches the network.
|
||||
router.post(
|
||||
"/plugin/inspect",
|
||||
sameOriginGuard,
|
||||
wrap(async (req, res) => res.json(await artifacts.inspectMarket(req.body?.market)))
|
||||
);
|
||||
|
||||
// A skill written in the dialog: {name, description, content, title?}.
|
||||
router.post(
|
||||
"/skill/new",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
const item = artifacts.writeSkill(req, req.body || {});
|
||||
changed(item);
|
||||
res.status(201).json({ item, items: [item] });
|
||||
})
|
||||
);
|
||||
|
||||
/** multer, in memory, for one upload route; errors answered like ours. */
|
||||
function upload(limits) {
|
||||
return (req, res, next) => {
|
||||
const multer = require("multer");
|
||||
multer({ storage: multer.memoryStorage(), limits }).array("files")(req, res, (err) => {
|
||||
if (!err) return next();
|
||||
const big = err.code === "LIMIT_FILE_SIZE" || err.code === "LIMIT_FILE_COUNT";
|
||||
return res.status(big ? 413 : 400).json({
|
||||
error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: err.message },
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Each uploaded file with the relative path the browser sent in `paths` (a folder pick keeps it). */
|
||||
function uploads(req) {
|
||||
let paths = [];
|
||||
try {
|
||||
paths = JSON.parse(req.body?.paths || "[]");
|
||||
} catch {
|
||||
paths = [];
|
||||
}
|
||||
return (req.files || []).map((f, i) => ({
|
||||
path: typeof paths[i] === "string" && paths[i] ? paths[i] : f.originalname,
|
||||
data: f.buffer,
|
||||
}));
|
||||
}
|
||||
|
||||
// Skills from files: one Markdown file, a folder, several files, a .zip/.skill,
|
||||
// or several skills at once (server/lib/artifacts.js `skillsFromUpload`).
|
||||
router.post(
|
||||
"/skill/upload",
|
||||
sameOriginGuard,
|
||||
upload({ files: 400, fileSize: 10 * 1024 * 1024, fields: 10 }),
|
||||
wrap((req, res) => {
|
||||
const b = req.body || {};
|
||||
const items = artifacts.uploadSkills(req, {
|
||||
uploads: uploads(req),
|
||||
name: b.name,
|
||||
title: b.title,
|
||||
description: b.description,
|
||||
});
|
||||
for (const item of items) changed(item);
|
||||
res.status(201).json({ items });
|
||||
})
|
||||
);
|
||||
|
||||
// One file from the user's computer, kept like a pinned workspace file.
|
||||
router.post(
|
||||
"/file/upload",
|
||||
sameOriginGuard,
|
||||
upload({ files: 1, fileSize: 25 * 1024 * 1024, fields: 10 }),
|
||||
wrap((req, res) => {
|
||||
const f = (req.files || [])[0];
|
||||
if (!f) {
|
||||
return res.status(400).json({ error: { code: "ENOFILE", message: "attach the file as `files`" } });
|
||||
}
|
||||
const item = artifacts.uploadFile(req, {
|
||||
name: f.originalname,
|
||||
data: f.buffer,
|
||||
title: req.body?.title,
|
||||
description: req.body?.description,
|
||||
});
|
||||
changed(item);
|
||||
res.status(201).json({ item });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/plugin",
|
||||
sameOriginGuard,
|
||||
|
||||
+50
-1
@@ -22,13 +22,18 @@
|
||||
* `POST /sign-out-others` ends every login but this one. These writes take the
|
||||
* account from the login COOKIE only — a pane token (an agent acting as its
|
||||
* owner) may not change the owner's login. Each change is audited.
|
||||
* `/avatar` (server mode) is the account's own picture (server/lib/avatars.js):
|
||||
* GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes
|
||||
* (PNG/JPEG/GIF/WebP, at most 1 MB) and DELETE removes it — both writes are
|
||||
* account writes like the profile's. The sidebar shows it beside the name.
|
||||
*
|
||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||
* the theme in their own Claude Code settings, written as them.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const express = require("express");
|
||||
const { Router } = express;
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
@@ -162,6 +167,8 @@ const ACCOUNT_STATUS = {
|
||||
EBADFIELD: 400,
|
||||
EBADEMAIL: 400,
|
||||
EBADPASSWORD: 400,
|
||||
EBADIMAGE: 400,
|
||||
ETOOBIG: 413,
|
||||
EREAUTH: 403,
|
||||
ETOOMANY: 429,
|
||||
};
|
||||
@@ -237,6 +244,48 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
const avatars = require("../lib/avatars");
|
||||
|
||||
router.get("/avatar", (req, res) => {
|
||||
if (!auth.isServerMode()) return res.status(404).json({ error: { code: "ENOAVATAR" } });
|
||||
const user = req.user || auth.userFromRequest(req);
|
||||
if (!user) return unauth(res);
|
||||
const file = avatars.avatarFile(user.id);
|
||||
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
|
||||
res.set("Content-Type", file.type);
|
||||
res.set("X-Content-Type-Options", "nosniff");
|
||||
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
|
||||
// The client versions the URL with `avatarAt`, so a new picture is a new URL.
|
||||
res.set("Cache-Control", "private, max-age=86400");
|
||||
res.sendFile(file.path);
|
||||
});
|
||||
|
||||
router.put(
|
||||
"/avatar",
|
||||
sameOriginGuard,
|
||||
express.raw({ type: () => true, limit: avatars.MAX_AVATAR_BYTES }),
|
||||
accountWrite(async (req, user) => {
|
||||
avatars.setAvatar(user.id, req.body);
|
||||
return { out: accountView(user.id), action: "own avatar set" };
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/avatar",
|
||||
sameOriginGuard,
|
||||
accountWrite(async (_req, user) => {
|
||||
avatars.removeAvatar(user.id);
|
||||
return { out: accountView(user.id), action: "own avatar removed" };
|
||||
})
|
||||
);
|
||||
|
||||
// An avatar past its limit reaches here as express.raw's 413.
|
||||
router.use((e, _req, res, next) => {
|
||||
if (e && e.type === "entity.too.large")
|
||||
return res.status(413).json({ error: { code: "ETOOBIG", message: "the image is too large" } });
|
||||
return next(e);
|
||||
});
|
||||
|
||||
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
|
||||
function followClaudeTheme(req, mode) {
|
||||
if (mode !== "dark" && mode !== "light") return;
|
||||
|
||||
@@ -287,9 +287,32 @@ const BUCKET_COLS = ["model", "speed", "inference_geo", "service_tier"];
|
||||
* its session's `tool`, for the per-tool split.
|
||||
*/
|
||||
function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
return bookedTokenRows(
|
||||
{ total: "DATE(s.started_at, ?)", hour: "DATE(h.hour || ':00:00', ?)", args: [tzModifier] },
|
||||
whereClause,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same booking per UTC hour (`YYYY-MM-DDTHH` in `date`) and per session
|
||||
* (`session_id`): what the account pool sums over a run's own hours. The rest
|
||||
* of a total goes to the session's start hour.
|
||||
*/
|
||||
function hourlyTokenRows(whereClause, params) {
|
||||
return bookedTokenRows(
|
||||
{ total: "substr(s.started_at, 1, 13)", hour: "h.hour", args: [] },
|
||||
whereClause,
|
||||
params,
|
||||
{ bySession: true }
|
||||
);
|
||||
}
|
||||
|
||||
/** `slot.total`/`slot.hour` name the period of a total and of an hourly row (`slot.args` bound once each). */
|
||||
function bookedTokenRows(slot, whereClause, params, { bySession = false } = {}) {
|
||||
const totals = db
|
||||
.prepare(
|
||||
`SELECT tu.session_id, DATE(s.started_at, ?) as date, s.tool,
|
||||
`SELECT tu.session_id, ${slot.total} as date, s.tool,
|
||||
${BUCKET_COLS.map((c) => `tu.${c}`).join(", ")},
|
||||
tu.input_tokens + tu.baseline_input as input_tokens,
|
||||
tu.output_tokens + tu.baseline_output as output_tokens,
|
||||
@@ -304,10 +327,10 @@ function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
JOIN sessions s ON s.id = tu.session_id
|
||||
${whereClause}`
|
||||
)
|
||||
.all(tzModifier, ...params);
|
||||
.all(...slot.args, ...params);
|
||||
const hourly = db
|
||||
.prepare(
|
||||
`SELECT h.session_id, DATE(h.hour || ':00:00', ?) as date,
|
||||
`SELECT h.session_id, ${slot.hour} as date,
|
||||
${BUCKET_COLS.map((c) => `h.${c}`).join(", ")},
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) as ${c}`).join(", ")}
|
||||
FROM token_usage_hourly h
|
||||
@@ -315,7 +338,7 @@ function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
${whereClause}
|
||||
GROUP BY h.session_id, 2, ${BUCKET_COLS.map((c) => `h.${c}`).join(", ")}`
|
||||
)
|
||||
.all(tzModifier, ...params);
|
||||
.all(...slot.args, ...params);
|
||||
|
||||
const keyOf = (r) => [r.session_id, ...BUCKET_COLS.map((c) => r[c])].join("\u0001");
|
||||
const hoursByBucket = new Map();
|
||||
@@ -327,12 +350,18 @@ function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
|
||||
const out = new Map();
|
||||
const add = (date, bucket, values) => {
|
||||
const k = [date, bucket.tool, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
|
||||
const k = [
|
||||
date,
|
||||
bucket.tool,
|
||||
...(bySession ? [bucket.session_id] : []),
|
||||
...BUCKET_COLS.map((c) => bucket[c]),
|
||||
].join("\u0001");
|
||||
let row = out.get(k);
|
||||
if (!row) {
|
||||
row = {
|
||||
date,
|
||||
tool: bucket.tool,
|
||||
...(bySession ? { session_id: bucket.session_id } : {}),
|
||||
...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])),
|
||||
};
|
||||
for (const c of USAGE_COLS) row[c] = 0;
|
||||
@@ -697,6 +726,7 @@ function attachAgentCosts(agents) {
|
||||
module.exports = router;
|
||||
module.exports.calculateCost = calculateCost;
|
||||
module.exports.dailyTokenRows = dailyTokenRows;
|
||||
module.exports.hourlyTokenRows = hourlyTokenRows;
|
||||
module.exports.costWhere = costWhere;
|
||||
module.exports.computeCost = computeCost;
|
||||
module.exports.agentOwnCost = agentOwnCost;
|
||||
|
||||
+22
-2
@@ -5,9 +5,12 @@
|
||||
* `GET /` lists every account with its workspaces, sessions and $ spent in the
|
||||
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
|
||||
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
||||
* (server/lib/audit.js).
|
||||
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
|
||||
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
|
||||
*
|
||||
* `GET /:id` is one account with the workspaces it owns, for the Edit user form.
|
||||
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
|
||||
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
|
||||
* owner groups — staff only like the rest of `/users`.
|
||||
*
|
||||
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
|
||||
* user form: display name, email, role, disabled, a new password, workspaces
|
||||
@@ -84,6 +87,11 @@ router.get("/", (_req, res) => {
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
router.get("/storage", (req, res) => {
|
||||
const { storageFor } = require("../lib/user-storage");
|
||||
res.json(storageFor(listUsers(), { refresh: req.query.refresh === "1" }));
|
||||
});
|
||||
|
||||
router.get("/audit", (req, res) => {
|
||||
res.json({ entries: listAudit(req.query.limit) });
|
||||
});
|
||||
@@ -149,6 +157,18 @@ router.get("/:id", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/:id/avatar", (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const file = Number.isSafeInteger(id) && id > 0 ? require("../lib/avatars").avatarFile(id) : null;
|
||||
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
|
||||
res.set("Content-Type", file.type);
|
||||
res.set("X-Content-Type-Options", "nosniff");
|
||||
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
|
||||
// Versioned by the client with the owner's `avatarAt`.
|
||||
res.set("Cache-Control", "private, max-age=86400");
|
||||
res.sendFile(file.path);
|
||||
});
|
||||
|
||||
/** A list of workspace ids from the body, or [] when absent. */
|
||||
function laneIds(value, field) {
|
||||
if (value === undefined) return [];
|
||||
|
||||
Reference in New Issue
Block a user