/** * @file artifacts.js * @description The Artifacts hub (docs/plans/artifacts/README.md): what agents * produce, kept per OWNER and — in server mode — shared to every user. * * Four kinds: * - `page` a claude.ai Artifact a session published: only a link, accepted * only as `https://claude.ai/…`. Captured from the `Artifact` * tool's PostToolUse hook (`captureFromHook`) or pasted by hand. * - `file` a file an agent wrote in a workspace, pinned by the user. * - `skill` a Claude Code skill folder (the user's own, or a workspace's * `.claude/skills/`). * - `plugin` a plugin by `name@marketplace` and its marketplace source; no * files. * * Rules: * - A `file` or `skill` is a SNAPSHOT taken when it is added, written under * `/artifacts//` and never touched again. Every read a * viewer makes is of that copy — no route reads another user's home for * them — and a path from a request only ever picks one of the snapshot's * own recorded files (`snapshotFile`). * - Visibility: in local mode every row; in server mode a developer sees * their own rows plus shared ones, admin and analyst see every row (like * sessions), and only the owner (or an admin) changes or deletes one. * - Installing a shared skill or plugin is the viewer's click, into the * VIEWER's own account only (`installTarget`): a member's home under S3 * (written AS them through `write-as`, `claude` run as them through sudo), * the host's Claude home for an admin or in local mode. An existing skill * is never overwritten without `replace`, and the old one is moved aside to * `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is * installed without `-y`, so one whose marketplace declares an install * command fails rather than runs unseen. An admin can turn installs off * for everyone (`setInstallsEnabled`). Share, unshare and install are * written to `audit_log`. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); const { promisify } = require("node:util"); const { getDataDir, getClaudeHome } = require("./claude-home"); const sql = (q) => require("../db").db.prepare(q); const fail = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra }); const isServer = () => require("./auth").isServerMode(); const FILE_MAX_BYTES = 25 * 1024 * 1024; const SKILL_MAX_BYTES = 5 * 1024 * 1024; const SKILL_MAX_FILES = 300; const SKILL_MAX_DEPTH = 6; const TITLE_MAX = 200; const DESCRIPTION_MAX = 2000; const KINDS = ["page", "file", "skill", "plugin"]; // What the file finder offers to pin: documents, not source code. const DOC_EXT = /\.(html?|md|markdown|mdx|pdf|png|jpe?g|gif|webp|svg|csv|tsv|json|ya?ml|txt|log|xlsx?|docx?|pptx?|ipynb|mp4|webm|mp3|wav|zip)$/i; const PAGE_URL_RE = /^https:\/\/claude\.ai\/[A-Za-z0-9._~\-/?#=&%]+$/; const PAGE_IN_TEXT_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+[A-Za-z0-9._~\-/?#=&%]*/; const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/; const SNAPSHOT_RE = /^[A-Za-z0-9_-]{32}$/; // A skill may not carry a login or a key: refused, never copied. const SECRET_NAME_RE = /(^\.env(\..*)?$|credential|secret|password|token|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|\.netrc$)/i; const root = () => path.join(getDataDir(), "artifacts"); const installsOffFlag = () => path.join(root(), "installs-off"); // ── Visibility ──────────────────────────────────────────────────────────── /** `{where, params}` over an `artifacts` alias `a`: the rows this request sees. */ function visibleWhere(req) { const u = req?.user; if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] }; return { where: "(a.owner_id = ? OR a.shared = 1)", params: [u.id] }; } const canSee = (req, row) => { const u = req?.user; if (!row) return false; if (!u || u.role === "admin" || u.role === "analyst") return true; return row.owner_id === u.id || row.shared === 1; }; /** Only the owner changes a row — or an admin, or anyone in local mode. */ const canChange = (req, row) => !req?.user || req.user.role === "admin" || row.owner_id === req.user.id; /** For scope.js's PATH_RULES: may this request name artifact `id`? */ function canSeeId(req, id) { return canSee(req, sql("SELECT owner_id, shared FROM artifacts WHERE id = ?").get(Number(id))); } function load(req, id) { const row = /^\d+$/.test(String(id)) ? sql("SELECT * FROM artifacts WHERE id = ?").get(Number(id)) : null; if (!canSee(req, row)) throw fail("ENOTFOUND", "artifact not found"); return row; } function view(row, req) { if (!row) return null; const owner = row.owner_id == null ? null : sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id); return { id: row.id, kind: row.kind, title: row.title, description: row.description, ownerId: row.owner_id, owner: owner ? owner.display_name || owner.username : null, mine: !req?.user || row.owner_id === req.user.id, canChange: canChange(req, row), laneId: row.lane_id, sessionId: row.session_id, source: row.source, market: row.market, url: row.url, files: row.files ? JSON.parse(row.files) : [], size: row.size, shared: row.shared === 1, sharedAt: row.shared_at, installs: row.installs, createdAt: row.created_at, updatedAt: row.updated_at, }; } /** * The rows this request may see. `scope`: `mine` (own rows; every row in * local mode), `hub` (shared rows, server mode), anything else = all visible. */ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) { const v = visibleWhere(req); const where = [v.where]; const params = [...v.params]; if (scope === "mine" && req?.user) { where.push("a.owner_id = ?"); params.push(req.user.id); } else if (scope === "hub") { where.push("a.shared = 1"); } if (kind) { if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`); where.push("a.kind = ?"); params.push(kind); } if (laneId != null && laneId !== "") { where.push("a.lane_id = ?"); params.push(Number(laneId)); } if (sessionId) { where.push("a.session_id = ?"); params.push(String(sessionId)); } if (q && String(q).trim()) { where.push("(a.title LIKE ? OR a.description LIKE ? OR a.source LIKE ?)"); const like = `%${String(q).trim().replace(/[%_]/g, (c) => `\\${c}`)}%`; params.push(like, like, like); } const n = Math.min(Math.max(Number(limit) || 200, 1), 500); const rows = sql( `SELECT a.* FROM artifacts a WHERE ${where.join(" AND ")} ORDER BY a.updated_at DESC, a.id DESC LIMIT ${n}` ).all(...params); return rows.map((r) => view(r, req)); } // ── Small helpers ───────────────────────────────────────────────────────── function cleanTitle(v, fallback) { const t = typeof v === "string" ? v.trim().slice(0, TITLE_MAX) : ""; return t || String(fallback || "Untitled").slice(0, TITLE_MAX); } const cleanDescription = (v) => typeof v === "string" ? v.trim().slice(0, DESCRIPTION_MAX) : ""; function insert(fields) { const cols = Object.keys(fields); const info = sql( `INSERT INTO artifacts (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})` ).run(...cols.map((c) => fields[c])); return sql("SELECT * FROM artifacts WHERE id = ?").get(Number(info.lastInsertRowid)); } function newSnapshotDir() { const id = crypto.randomBytes(24).toString("base64url"); const dir = path.join(root(), id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); return { id, dir }; } function dropSnapshot(id) { if (!id || !SNAPSHOT_RE.test(id)) return; fs.rmSync(path.join(root(), id), { recursive: true, force: true }); } function laneOf(laneId) { const lane = require("./lanes").getLane(Number(laneId)); if (!lane) throw fail("ENOLANE", "workspace not found"); return lane; } /** The owner of a session's workspace (null: unknown or local mode). */ function sessionOwner(sessionId) { const owners = [...require("./scope").sessionOwners(sessionId)]; return owners.length ? owners[0] : null; } /** A session's workspace, if one owns it. */ function sessionLane(sessionId) { const row = sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sessionId)) || sql("SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL LIMIT 1").get( String(sessionId) ); return row?.lane_id ?? null; } // ── page ────────────────────────────────────────────────────────────────── function validPageUrl(url) { const u = typeof url === "string" ? url.trim() : ""; if (!PAGE_URL_RE.test(u) || u.length > 2000) { throw fail("EBADURL", "a claude.ai link (https://claude.ai/…) is required"); } return u; } /** Add (or retitle) a claude.ai page by hand. */ function addPage(req, { url, title, description, laneId, sessionId }) { const u = validPageUrl(url); const ownerId = req?.user ? req.user.id : null; const same = sql( "SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?" ).get(u, ownerId); if (same) { sql( "UPDATE artifacts SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?" ).run(cleanTitle(title, same.title), same.id); return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req); } return view( insert({ kind: "page", title: cleanTitle(title, u.replace(/^https:\/\//, "")), description: cleanDescription(description), owner_id: ownerId, lane_id: laneId != null && laneId !== "" ? Number(laneId) : null, session_id: sessionId ? String(sessionId) : null, url: u, }), req ); } /** * The `Artifact` tool's PostToolUse hook: a publish answers with the page's * claude.ai URL somewhere in its response. Recorded once per (owner, url), * owned by the session's workspace owner. Returns the row or null. */ function captureFromHook(sessionId, data) { const input = data?.tool_input || {}; if (input.action && input.action !== "publish") return null; const text = JSON.stringify(data?.tool_response ?? ""); const m = PAGE_IN_TEXT_RE.exec(text); if (!m) return null; const url = m[0].replace(/[\\"]+$/, ""); if (!PAGE_URL_RE.test(url)) return null; const ownerId = sessionOwner(sessionId); const same = sql( "SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?" ).get(url, ownerId); if (same) return same; const file = typeof input.file_path === "string" ? input.file_path : ""; return insert({ kind: "page", title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"), description: cleanDescription(input.description), owner_id: ownerId, lane_id: sessionLane(sessionId), session_id: String(sessionId), source: file ? file.slice(0, 1000) : null, url, }); } // ── file ────────────────────────────────────────────────────────────────── /** * Files the workspace's sessions wrote (Write/Edit/MultiEdit/NotebookEdit), * documents only, inside the workspace folder, newest first — what the user * can pin. `pinned` marks the ones already kept. */ function fileCandidates(laneId) { const lane = laneOf(laneId); const rows = sql( `SELECT session_id, COALESCE(json_extract(data, '$.tool_input.file_path'), json_extract(data, '$.tool_input.notebook_path')) AS fp, created_at FROM events WHERE event_type = 'PostToolUse' AND tool_name IN ('Write', 'Edit', 'MultiEdit', 'NotebookEdit') AND session_id IN (SELECT id FROM sessions WHERE lane_id = ? UNION SELECT session_id FROM dashboard_runs WHERE lane_id = ? AND session_id IS NOT NULL) ORDER BY id DESC LIMIT 5000` ).all(lane.id, lane.id); const pinned = new Set( sql("SELECT source FROM artifacts WHERE kind = 'file' AND lane_id = ?") .all(lane.id) .map((r) => r.source) ); const seen = new Set(); const out = []; for (const r of rows) { if (typeof r.fp !== "string" || !path.isAbsolute(r.fp) || !DOC_EXT.test(r.fp)) continue; const rel = path.relative(lane.cwd, r.fp); if (!rel || rel.startsWith("..") || path.isAbsolute(rel) || seen.has(rel)) continue; seen.add(rel); let exists = false; try { exists = fs.lstatSync(r.fp).isFile(); } catch { exists = false; } out.push({ path: rel.split(path.sep).join("/"), sessionId: r.session_id, at: r.created_at, exists, pinned: pinned.has(rel.split(path.sep).join("/")), }); if (out.length >= 200) break; } return out; } /** Pin one file of a workspace: a snapshot of it, under the read path's guards. */ async function pinFile(req, { laneId, path: rel, title, description, sessionId }) { const lane = laneOf(laneId); const { openDownload } = require("./lane-file-transfer"); const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : ""); if (file.size > FILE_MAX_BYTES) { file.stream.destroy(); throw fail("ETOOBIG", "a file artifact is at most 25 MB"); } const snap = newSnapshotDir(); const name = file.name; try { await new Promise((resolve, reject) => { const out = fs.createWriteStream(path.join(snap.dir, name), { mode: 0o600 }); file.stream.on("error", reject); out.on("error", reject); out.on("finish", resolve); file.stream.pipe(out); }); } catch (err) { dropSnapshot(snap.id); throw err; } const relPosix = require("./git-diff").lanePath(rel); return view( insert({ kind: "file", title: cleanTitle(title, name), description: cleanDescription(description), owner_id: req?.user ? req.user.id : lane.owner_id ?? null, lane_id: lane.id, session_id: sessionId ? String(sessionId) : null, source: relPosix, snapshot: snap.id, files: JSON.stringify([{ path: name, size: file.size }]), size: file.size, }), req ); } /** * 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 ───────────────────────────────────────────────────────────────── /** * The account a request's own Claude config lives in: `{acct, claudeHome}`. * A member under S3 (their home), else the host's Claude home — only in * local mode or for an admin, since it is the host's own configuration. */ function ownAccount(req) { if (!req?.user) return { acct: null, claudeHome: getClaudeHome() }; const runAs = require("./run-as"); let acct = null; try { acct = runAs.accountForUser(req.user.username); } catch { acct = null; } if (acct) return { acct, claudeHome: path.join(acct.home, ".claude") }; if (req.user.role === "admin") return { acct: null, claudeHome: getClaudeHome() }; throw fail( "ENOACCOUNT", "your account has no Claude config of its own on this server (one Linux user per account is not set up)" ); } /** Skill folders the request may share: its own and the workspace's. */ function availableSkills(req, laneId) { const out = []; const scan = (dir, from) => { let names = []; try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const d of names) { if (!d.isDirectory() || !SKILL_NAME_RE.test(d.name)) continue; try { if (fs.lstatSync(path.join(dir, d.name, "SKILL.md")).isFile()) { out.push({ name: d.name, from }); } } catch { /* not a skill */ } } }; try { scan(path.join(ownAccount(req).claudeHome, "skills"), "user"); } catch { /* no own config: only the workspace's */ } if (laneId != null && laneId !== "") { scan(path.join(laneOf(laneId).cwd, ".claude", "skills"), "workspace"); } return out.sort((a, b) => a.name.localeCompare(b.name)); } /** Every regular file under `dir`, capped; a symlink is refused, never followed. */ function walkSkill(dir) { const files = []; let total = 0; const walk = (abs, rel, depth) => { if (depth > SKILL_MAX_DEPTH) throw fail("ETOOBIG", "the skill folder is nested too deep"); for (const d of fs.readdirSync(abs, { withFileTypes: true })) { if (d.name === ".git" || d.name === "node_modules") continue; const childAbs = path.join(abs, d.name); const childRel = rel ? `${rel}/${d.name}` : d.name; const st = fs.lstatSync(childAbs); if (st.isSymbolicLink()) throw fail("ESYMLINK", `the skill holds a symlink: ${childRel}`); if (st.isDirectory()) { walk(childAbs, childRel, depth + 1); continue; } if (!st.isFile()) continue; if (SECRET_NAME_RE.test(d.name)) { throw fail("ESECRET", `the skill holds a file that looks like a secret: ${childRel}`); } total += st.size; if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files"); if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB"); files.push({ abs: childAbs, path: childRel, size: st.size }); } }; walk(dir, "", 0); return { files, total }; } const inside = (parent, child) => { const rel = path.relative(parent, child); return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); }; /** Snapshot one skill folder — the user's own (`laneId` absent) or a workspace's. */ function addSkill(req, { name, laneId, title, description }) { if (typeof name !== "string" || !SKILL_NAME_RE.test(name)) { throw fail("EBADNAME", "a skill folder name"); } let base; let lane = null; if (laneId != null && laneId !== "") { lane = laneOf(laneId); base = path.join(lane.cwd, ".claude", "skills"); } else { base = path.join(ownAccount(req).claudeHome, "skills"); } const dir = path.join(base, name); let real; try { real = fs.realpathSync(dir); } catch { throw fail("ENOFILE", "no such skill"); } // The folder must really be where its name says (no symlinked skill). const top = lane ? fs.realpathSync(lane.cwd) : fs.realpathSync(path.dirname(base)); if (!inside(top, real) || path.basename(real) !== name) { 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 } = 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, data, { mode: 0o600 }); recorded.push({ path: f.path, size: data.length }); } } catch (err) { dropSnapshot(snap.id); throw err; } let summary = ""; try { summary = frontmatterField(fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"), "description"); } catch { /* no frontmatter */ } return view( insert({ kind: "skill", title: cleanTitle(title, name), description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX), owner_id: req?.user ? req.user.id : null, lane_id: laneId, source: name, snapshot: snap.id, 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. */ function validMarket(market) { const m = typeof market === "string" ? market.trim() : ""; if (GITHUB_REPO_RE.test(m)) return m; try { return require("./git-ops").validateRemoteUrl(m); } catch { throw fail("EBADMARKET", "marketplace: owner/repo or an https/ssh git URL"); } } 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", title: cleanTitle(title, k), description: cleanDescription(description), owner_id: req?.user ? req.user.id : null, source: k, market: src, }), req ); } // ── change / delete ─────────────────────────────────────────────────────── function audit(req, row, action) { if (!req?.user) return; try { require("./audit").recordAccountChange( req.user.id, row.owner_id ?? null, "POST", `artifact ${action}: #${row.id} ${row.kind} "${row.title}"` ); } catch (err) { console.warn("[artifacts] could not audit:", err.message); } } function update(req, id, body = {}) { const row = load(req, id); if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact"); const sets = []; const params = []; if ("title" in body) { sets.push("title = ?"); params.push(cleanTitle(body.title, row.title)); } if ("description" in 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"); if (!isServer()) throw fail("ELOCALMODE", "sharing needs server mode"); if (body.shared !== (row.shared === 1)) { shareChange = body.shared ? "shared" : "unshared"; sets.push("shared = ?", "shared_at = ?"); params.push(body.shared ? 1 : 0, body.shared ? new Date().toISOString() : null); } } if (!sets.length) return view(row, req); sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"); sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id); const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id); if (shareChange) audit(req, next, shareChange); return view(next, req); } function remove(req, id) { const row = load(req, id); if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact"); sql("DELETE FROM artifacts WHERE id = ?").run(row.id); dropSnapshot(row.snapshot); if (row.shared === 1) audit(req, row, "deleted"); return row; } /** The absolute path of one RECORDED file of a row's snapshot (`rel` must match). */ function snapshotFile(req, id, rel) { const row = load(req, id); if (!row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) throw fail("ENOFILE", "no files"); const files = row.files ? JSON.parse(row.files) : []; const want = typeof rel === "string" && rel ? rel : files[0]?.path; const hit = files.find((f) => f.path === want); if (!hit) throw fail("ENOFILE", "no such file in this artifact"); return { row, abs: path.join(root(), row.snapshot, ...hit.path.split("/")), name: path.basename(hit.path), size: hit.size }; } // ── install ─────────────────────────────────────────────────────────────── const installsEnabled = () => !fs.existsSync(installsOffFlag()); function setInstallsEnabled(on) { fs.mkdirSync(root(), { recursive: true, mode: 0o700 }); if (on) fs.rmSync(installsOffFlag(), { force: true }); else fs.writeFileSync(installsOffFlag(), "", { mode: 0o600 }); return installsEnabled(); } function installable(req, row) { if (row.kind !== "skill" && row.kind !== "plugin") { throw fail("EBADKIND", "only a skill or a plugin is installed"); } if (!installsEnabled()) throw fail("EINSTALLSOFF", "an admin turned Hub installs off"); } /** Install a skill snapshot into the viewer's own `~/.claude/skills/`. */ async function installSkill(req, row, { replace = false } = {}) { const { acct, claudeHome } = ownAccount(req); const runAs = require("./run-as"); const skills = path.join(claudeHome, "skills"); const target = path.join(skills, row.source); const taken = (() => { try { fs.lstatSync(target); return true; } catch { return false; } })(); if (taken && !replace) throw fail("EEXISTS", `a skill named ${row.source} is already installed`); if (taken) { const aside = path.join( claudeHome, "agenthub-replaced-skills", `${row.source}-${new Date().toISOString().replace(/[:.]/g, "-")}` ); if (acct) { if (!runAs.writeAsCanMove()) { throw fail("EUPGRADE", "replacing needs the updated helper: run `agenthub server install-system` once"); } runAs.mkdirAs(acct, path.dirname(aside)); await runAs.writeAsAsync(acct, target, ["move", aside]); } else { fs.mkdirSync(path.dirname(aside), { recursive: true }); fs.renameSync(target, aside); } } const files = row.files ? JSON.parse(row.files) : []; for (const f of files) { const from = path.join(root(), row.snapshot, ...f.path.split("/")); const to = path.join(target, ...f.path.split("/")); const content = fs.readFileSync(from); if (acct) { runAs.mkdirAs(acct, path.dirname(to)); runAs.writeFileAs(to, content); } else { fs.mkdirSync(path.dirname(to), { recursive: true }); fs.writeFileSync(to, content); } } return { installed: target, replaced: taken }; } let execImpl = promisify(require("node:child_process").execFile); function __setExecImpl(fn) { execImpl = fn; } /** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */ async function installPlugin(req, row) { const { acct } = ownAccount(req); const steps = [ ["plugin", "marketplace", "add", validMarket(row.market)], ["plugin", "install", "--scope", "user", row.source], ]; if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace"); let run; if (acct) { const runAs = require("./run-as"); const claude = require("./shared-tools").sharedPath("claude"); if (!claude) throw fail("ENOTSHARED", "claude is not in the shared tools yet"); const env = { HOME: acct.home, DISABLE_AUTOUPDATER: "1" }; run = (args) => { const [cmd, argv] = runAs.sudoArgv(acct, claude, args, env); return execImpl(cmd, argv, { cwd: acct.home, timeout: 300_000, encoding: "utf8" }); }; } else { const env = { ...process.env, DISABLE_AUTOUPDATER: "1" }; if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome(); run = (args) => execImpl("claude", args, { timeout: 300_000, encoding: "utf8", env }); } const lastLine = (e) => String(e?.stderr || e?.stdout || e?.message || "") .trim() .split("\n") .pop(); try { await run(steps[0]); } catch (e) { // Already added is fine; anything else is the answer. if (!/already/i.test(String(e?.stderr || "") + String(e?.stdout || ""))) { throw fail("EPLUGIN", `marketplace: ${lastLine(e)}`); } } try { await run(steps[1]); } catch (e) { throw fail("EPLUGIN", `install: ${lastLine(e)}`); } return { installed: row.source }; } async function install(req, id, opts = {}) { const row = load(req, id); installable(req, row); const result = row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row); sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id); if (req?.user && row.owner_id !== req.user.id) audit(req, row, "installed"); return result; } module.exports = { KINDS, DOC_EXT, list, load, view, canSeeId, addPage, captureFromHook, fileCandidates, pinFile, availableSkills, addSkill, uploadSkills, writeSkill, uploadFile, skillSlug, withFrontmatter, normalizeMarket, inspectMarket, addPlugin, update, remove, snapshotFile, install, installsEnabled, setInstallsEnabled, __setExecImpl, __setInspectImpl, };