Files
AgentHub/server/lib/artifacts.js
T
nntrivi2001 62b4520507 feat(artifacts): capture Claude Docs docs and session documents, claim unowned rows
- A Claude Docs `batch` that creates a doc is captured like an `Artifact`
  publish, titled by the doc's name and linked by its own frame URL.
- At Stop/SessionEnd, each pptx/docx/xlsx/pdf/html a workspace session
  created or changed is snapshotted into its owner's library (`auto`);
  an unchanged file is skipped, a changed one refreshes its snapshot.
- Server mode: a page with no owner carries a suggested owner (the
  workspace or home its session ran in). The suggested user claims or
  declines it; an admin claims or assigns any. New tab Unowned /
  Is this yours? on the Artifacts page; all three actions audited.
2026-10-23 08:33:55 +07:00

1958 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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`; a Claude Docs doc's
* birth too) or pasted by hand. In server mode a row nobody owns
* waits for its suggested user or an admin (`claim`/`decline`/`assign`).
* - `file` a file an agent wrote in a workspace, pinned by the user — or a
* document (pptx/docx/xlsx/pdf/html) a session created, captured
* when it stops (`captureOutputs`, `auto`).
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
* `.claude/skills/<name>`).
* - `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
* `<data dir>/artifacts/<snapshot>/` 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 accounts only (`installAccounts`): a member's home under S3
* (written AS them through `write-as`, `claude` run as them through sudo),
* plus the host's home for an admin (their host-folder workspaces run as
* the host), or the host alone in local mode. A skill goes into BOTH
* `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of each, so
* every AI tool and every session of the viewer finds it. An existing skill
* is never overwritten without `replace`, and the old one is moved aside to
* `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is
* installed without `-y`, so one whose marketplace declares an install
* command fails rather than runs unseen. An admin can turn installs off
* for everyone (`setInstallsEnabled`). Uninstall (`uninstall`) takes it out
* of the same accounts again — a skill moved aside the same way, a plugin
* `claude plugin uninstall --keep-data` — and is allowed with installs off.
* Share, unshare, install and uninstall are written to `audit_log`.
* - Editing a skill's name, description or instructions (`editSkill`) writes
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
* copies already installed stay as they were (uninstall and install again
* to take the new one).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?))",
params: [u.id, 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 ||
(row.owner_id == null && row.suggested_owner_id === u.id)
);
};
/** 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, suggested_owner_id 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, owners = null) {
if (!row) return null;
let owner = null;
if (row.owner_id != null) {
// A list passes one cache, so each owner is looked up once, not per row.
if (owners?.has(row.owner_id)) owner = owners.get(row.owner_id);
else {
owner =
sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id) || null;
owners?.set(row.owner_id, owner);
}
}
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,
inLibrary: row.in_library !== 0,
installs: row.installs,
auto: row.auto === 1,
// Server mode only: a row nobody owns waits for an admin or its suggested user.
unowned: isServer() && row.owner_id == null,
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* The rows this request may see. `scope`: `mine` (own library rows plus the
* shared skills/plugins of others the caller has installed; every row in
* local mode), `hub` (shared rows, server mode), `unowned` (server mode: rows
* nobody owns — every one for an admin, those suggested to the caller for
* anyone else), anything else = all visible.
* Each row also carries `installed` (`installedCheck`).
*/
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
const v = visibleWhere(req);
const where = [v.where];
const params = [...v.params];
if (scope === "mine" && req?.user) {
// Their own library, plus what others shared that they installed (kept
// below only when `installed` — a disk read, so it cannot be SQL).
where.push(
"((a.owner_id = ? AND a.in_library = 1) OR (a.shared = 1 AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))"
);
params.push(req.user.id, req.user.id);
} else if (scope === "hub") {
where.push("a.shared = 1");
} else if (scope === "unowned") {
if (!isServer() || !req?.user) return [];
where.push("a.owner_id IS NULL");
if (req.user.role !== "admin") {
where.push("a.suggested_owner_id = ?");
params.push(req.user.id);
}
}
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()) {
// `\` escapes `%`/`_` (and itself) only with an ESCAPE clause: SQLite
// has no default escape character, so without it `my_skill` found nothing.
where.push(
"(a.title LIKE ? ESCAPE '\\' OR a.description LIKE ? ESCAPE '\\' OR a.source LIKE ? ESCAPE '\\')"
);
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);
const owners = new Map();
const installed = installedCheck(req);
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
}
// ── 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 = ?, in_library = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(cleanTitle(title, same.title), same.id);
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
}
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
);
}
/**
* Who a hook's page belongs to when no workspace owns its session (a member's
* `claude` started from their own terminal): the S3 member the hook's
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
* The host's account is shared by every admin, so it names nobody.
*/
/**
* Whose a row with no owner probably is, from the folder its session ran in:
* the owner of the workspace holding that folder, else (S3) the member whose
* home it is. Only a suggestion — that user confirms (`claim`). Server mode only.
*/
function suggestOwner(cwd) {
if (!isServer() || typeof cwd !== "string" || !path.isAbsolute(cwd)) return null;
const lane = require("./lanes").resolveLaneByCwd(cwd);
if (lane?.owner_id != null) return lane.owner_id;
try {
const acct = require("./run-as").ownerForRead(cwd);
if (!acct?.name) return null;
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(acct.name);
return row ? row.id : null;
} catch {
return null;
}
}
function peerOwner(peer) {
if (peer?.kind !== "member" || !peer.name) return null;
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
return row ? row.id : null;
}
/**
* A claude.ai connector's `batch` tool (Claude Docs today, `mcp__claude_ai_Claude_Docs__batch`):
* the one that births a doc. Slides, dashboards and designs are made through the
* `Artifact` tool (`type_url`) and are captured as its publish.
*/
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
/** Whether a PostToolUse of this tool may have published a claude.ai page. */
function isPageTool(toolName) {
return toolName === "Artifact" || DOCS_BATCH_RE.test(String(toolName || ""));
}
/** The `container.create` of a Claude Docs `batch` — set only when it births a doc. */
function docsBirth(input) {
let container = input.container;
if (typeof container === "string") {
try {
container = JSON.parse(container);
} catch {
return null;
}
}
const create = container?.create;
return create && typeof create === "object" ? create : null;
}
/**
* `frame.artifactUrl` (or `frame.url`) of a Claude Docs ack. The ack is JSON with a prose
* trailer appended in the same text block, so it is matched, not parsed.
*/
const FRAME_URL_RE = /"frame":\{[^{}]*?"(?:artifactUrl|url)":"(https:\/\/claude\.ai\/[^"]+)"/;
function docsFrameUrl(response) {
const blocks = Array.isArray(response) ? response : [response];
for (const b of blocks) {
const raw = typeof b === "string" ? b : b?.text;
const m = typeof raw === "string" ? FRAME_URL_RE.exec(raw) : null;
if (m && PAGE_URL_RE.test(m[1])) return m[1];
}
return null;
}
/**
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
* claude.ai URL somewhere in its response. A Claude Docs `batch` that births a
* doc answers with the doc's link the same way (later batches edit it, and are
* skipped). Recorded once per (owner, url), owned by the session's workspace
* owner, else by the member who sent the hook (`peerOwner`). Returns the row
* or null.
*/
function captureFromHook(sessionId, data, peer = null) {
const input = data?.tool_input || {};
const docs = DOCS_BATCH_RE.test(String(data?.tool_name || ""));
const birth = docs ? docsBirth(input) : null;
if (docs && !birth) return null;
if (input.action && input.action !== "publish") return null;
// A doc's own link is its ack's `frame`; the first link in the text may be one the doc's content cites.
const framed = birth ? docsFrameUrl(data?.tool_response) : null;
const text = JSON.stringify(data?.tool_response ?? "");
const m = framed ? [framed] : PAGE_IN_TEXT_RE.exec(text);
if (!m) return null;
const url = m[0].replace(/[\\"]+$/, "");
if (!PAGE_URL_RE.test(url)) return null;
const laneId = sessionLane(sessionId);
const ownerId = laneId != null ? sessionOwner(sessionId) : peerOwner(peer);
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(birth ? birth.name : input.title, file ? path.basename(file) : "Artifact"),
description: cleanDescription(input.description),
owner_id: ownerId,
lane_id: laneId,
session_id: String(sessionId),
source: file ? file.slice(0, 1000) : null,
url,
suggested_owner_id: ownerId == null ? suggestOwner(data?.cwd) : null,
});
}
// ── 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;
}
/** A new snapshot of one workspace file, read under the read path's guards. */
async function snapshotLaneFile(lane, rel) {
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();
try {
await new Promise((resolve, reject) => {
const out = fs.createWriteStream(path.join(snap.dir, file.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;
}
return { snap, name: file.name, size: file.size };
}
/** 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 { snap, name, size } = await snapshotLaneFile(lane, rel);
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 }]),
size,
}),
req
);
}
// ── Outputs captured when a session stops ───────────────────────────────────
/** Documents a session makes for people (slides, Word, Excel, PDF, HTML reports). */
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
const OUTPUT_MAX = 20;
const capturing = new Map();
/**
* The workspace's documents a session created or changed since it started:
* untracked or modified files in a repo (an HTML file only when untracked, so a
* site's own pages are not taken), every file of a plain folder. Paths are
* workspace-relative.
*/
async function outputCandidates(lane, since) {
const gd = require("./git-diff");
let paths;
let tracked = new Set();
if (await gd.inRepo(lane.cwd)) {
const untracked = (await gd.lsFiles(lane.cwd, ["--others", "--exclude-standard"])).all;
const modified = (await gd.lsFiles(lane.cwd, ["--modified"])).all;
tracked = new Set(modified);
paths = [...untracked, ...modified];
} else {
paths = (await gd.walkFolder(lane.cwd)).files;
}
const out = [];
for (const rel of new Set(paths)) {
if (!OUTPUT_EXT.test(rel)) continue;
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
let st;
try {
st = await fs.promises.lstat(path.join(lane.cwd, rel));
} catch {
continue;
}
if (!st.isFile() || st.mtimeMs < since || st.size > FILE_MAX_BYTES) continue;
out.push({ rel: gd.lanePath(rel), mtimeMs: st.mtimeMs });
}
return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, OUTPUT_MAX);
}
/**
* At a session's Stop: snapshot each document it created or changed into its
* workspace owner's Artifacts, marked `auto`. A file seen again with the mtime
* its snapshot recorded is skipped; a changed one replaces the row's snapshot. A file the user
* pinned by hand is left to them. Returns the rows added or refreshed.
*/
function captureOutputs(sessionId) {
const laneId = sessionLane(sessionId);
if (laneId == null) return Promise.resolve([]);
// One capture per workspace at a time: two Stops in a row must not both insert.
const prev = capturing.get(laneId) || Promise.resolve();
const run = prev.then(() => captureOutputsNow(laneId, sessionId)).catch(() => []);
capturing.set(laneId, run);
run.finally(() => {
if (capturing.get(laneId) === run) capturing.delete(laneId);
});
return run;
}
async function captureOutputsNow(laneId, sessionId) {
const lane = require("./lanes").getLane(Number(laneId));
if (!lane) return [];
const started = sql("SELECT started_at FROM sessions WHERE id = ?").get(String(sessionId));
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
const done = [];
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
const rows = sql("SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?").all(
lane.id,
rel
);
if (rows.some((r) => r.auto !== 1)) continue;
const row = rows[0];
const kept = row?.files ? JSON.parse(row.files)[0] : null;
if (kept && kept.mtime === mtimeMs) continue;
let shot;
try {
shot = await snapshotLaneFile(lane, rel);
} catch {
continue; // a secret name, a vanished file, one grown past the cap
}
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
if (row) {
sql(
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
dropSnapshot(row.snapshot);
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
} else {
done.push(
insert({
kind: "file",
title: cleanTitle(null, shot.name),
owner_id: lane.owner_id ?? null,
lane_id: lane.id,
session_id: String(sessionId),
source: rel,
snapshot: shot.snap.id,
files,
size: shot.size,
auto: 1,
})
);
}
}
return done;
}
/**
* 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, " ");
// Plain only when YAML cannot read it as anything else: starts with a
// letter or digit (never `-`, `?`, `[`… — `- note` is a list item), no
// trailing space, not a bare number. Anything else goes in double quotes.
const quote = (v) =>
/^[A-Za-z0-9][\w .,()/-]*$/.test(v) && !/\s$/.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));
// Replace a top-level key AND its continuation lines (a `>`/`|` block or a
// wrapped value is indented under it) — else the old text stays behind
// and YAML folds it into the new value.
const setKey = (k, value) => {
const out = [];
let skipping = false;
for (const l of lines) {
if (new RegExp(`^${k}:`).test(l)) {
out.push(`${k}: ${value}`);
skipping = true;
continue;
}
if (skipping && (/^\s/.test(l) || l === "")) continue;
skipping = false;
out.push(l);
}
return out;
};
lines = has("name") ? setKey("name", name) : [`name: ${name}`, ...lines];
if (desc) {
lines = has("description")
? setKey("description", quote(desc))
: [...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}`);
// Read at most 1 MB: a link the user names may serve anything, and
// `res.text()` would hold all of it in memory before the size check.
const tooBig = () => fail("ETOOBIG", "marketplace.json is larger than 1 MB");
if (Number(res.headers.get("content-length")) > MARKET_JSON_MAX) {
res.body?.cancel().catch(() => {});
throw tooBig();
}
const chunks = [];
let size = 0;
const reader = res.body?.getReader();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > MARKET_JSON_MAX) {
reader.cancel().catch(() => {});
throw tooBig();
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks).toString("utf8");
}
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 = ?, in_library = 1,
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 ───────────────────────────────────────────────────────
// ── Unowned rows ──────────────────────────────────────────────────────────
function unownedRow(req, id) {
const row = load(req, id);
if (!isServer()) throw fail("ELOCALMODE", "only server mode has owners");
if (row.owner_id != null) throw fail("EOWNED", "this artifact already has an owner");
return row;
}
/** "It is mine": the suggested user (or an admin, for themselves) takes an unowned row. */
function claim(req, id) {
const row = unownedRow(req, id);
if (req.user.role !== "admin" && row.suggested_owner_id !== req.user.id) {
throw fail("EFORBIDDEN", "only the suggested user or an admin can claim it");
}
sql(
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(req.user.id, row.id);
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
audit(req, next, "claim");
return view(next, req);
}
/** "Not mine": the suggestion goes; the row stays unowned for an admin to place. */
function decline(req, id) {
const row = unownedRow(req, id);
if (row.suggested_owner_id !== req.user.id) {
throw fail("EFORBIDDEN", "only the suggested user can decline it");
}
sql("UPDATE artifacts SET suggested_owner_id = NULL WHERE id = ?").run(row.id);
audit(req, row, "decline");
return { id: row.id, declined: true };
}
/** An admin gives an unowned row to an enabled user. */
function assign(req, id, userId) {
const row = unownedRow(req, id);
if (req.user.role !== "admin") throw fail("EFORBIDDEN", "only an admin assigns an owner");
const user = /^\d+$/.test(String(userId))
? sql("SELECT id FROM users WHERE id = ? AND disabled = 0").get(Number(userId))
: null;
if (!user) throw fail("EBADFIELD", "userId: an enabled user is required");
sql(
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(user.id, row.id);
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
audit(req, next, "assign");
return view(next, req);
}
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 = {}) {
let row = load(req, id);
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
if (body.skill && typeof body.skill === "object") {
editSkill(req, row, body.skill);
row = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
if (!("title" in body) && !("shared" in body) && !("description" in body))
return view(row, req);
}
const sets = [];
const params = [];
if ("title" in body) {
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);
if (shareChange === "unshared" && row.in_library === 0) {
// Off the Hub and out of its owner's library: nothing keeps it.
deleteRow(row);
audit(req, row, "unshared");
return { ...view(row, req), shared: false, removed: true };
}
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
if (shareChange) audit(req, next, shareChange);
return view(next, req);
}
function deleteRow(row) {
sql("DELETE FROM artifact_installs WHERE artifact_id = ?").run(row.id);
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
dropSnapshot(row.snapshot);
}
/**
* Delete from the caller's library. A SHARED row its owner deletes stays in
* the Hub (`in_library = 0`, `{kept: true}`) — only unsharing takes it off
* there. Anything else (unshared, already Hub-only, or an admin removing
* someone else's) is deleted with its snapshot.
*/
function remove(req, id) {
const row = load(req, id);
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
const owner = !req?.user || row.owner_id === req.user.id;
if (owner && row.shared === 1 && row.in_library !== 0) {
sql(
"UPDATE artifacts SET in_library = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(row.id);
return { ...row, in_library: 0, kept: true };
}
deleteRow(row);
if (row.shared === 1) audit(req, row, "deleted");
return row;
}
/**
* New contents for a skill row: `name` (the folder and /command),
* `description` (frontmatter — also the card's description) and `content`
* (SKILL.md's body, frontmatter excluded). The other frontmatter keys and
* every other file are kept. A new snapshot replaces the old one.
*/
function editSkill(req, row, { name, description, content }) {
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill's contents are edited");
if (!row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) throw fail("ENOFILE", "no files");
const n = validSkillName(name);
const body = typeof content === "string" ? content : "";
if (!body.trim()) throw fail("EBADSKILL", "write the skill's instructions");
const recorded = row.files ? JSON.parse(row.files) : [];
const files = recorded.map((f) => ({
path: f.path,
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
}));
const md = files.find((f) => f.path === "SKILL.md");
const old = md ? md.data.toString("utf8").replace(/^\uFEFF/, "") : "";
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(old);
const text = withFrontmatter(
`${fm ? fm[0] : ""}${fm ? "\n" : ""}${body.replace(/^\s+/, "")}`,
n,
description
);
const all = [
{ path: "SKILL.md", data: Buffer.from(text, "utf8") },
...files.filter((f) => f !== md),
];
checkSkillFiles(all);
const snap = newSnapshotDir();
let total = 0;
const next = [];
try {
for (const f of all) {
const target = path.join(snap.dir, ...f.path.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, f.data, { mode: 0o600 });
total += f.data.length;
next.push({ path: f.path, size: f.data.length });
}
} catch (err) {
dropSnapshot(snap.id);
throw err;
}
const desc = frontmatterField(text, "description");
sql(
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
dropSnapshot(row.snapshot);
}
/** The absolute path of one RECORDED file of a row's snapshot (`rel` must match). */
function snapshotFile(req, id, rel) {
const row = load(req, id);
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");
}
/**
* Every account a request's sessions run as: `[{acct, home, claudeHome}]`.
* Under S3 a pane runs as its FOLDER's account, so an admin with a member
* account of their own also runs host-folder workspaces as the host — both
* get the skill, or half their sessions never see it.
*/
function installAccounts(req) {
const hostHome = path.dirname(getClaudeHome());
const host = { acct: null, home: hostHome, claudeHome: getClaudeHome() };
if (!req?.user) return [host];
const own = ownAccount(req);
const out = [];
if (own.acct) out.push({ acct: own.acct, home: own.acct.home, claudeHome: own.claudeHome });
if (req.user.role === "admin") out.push(host);
return out;
}
/**
* Where an account's AI tools look for user skills: Claude Code's
* `~/.claude/skills` (also read by OpenCode, Cursor, omp, Grok) and the
* cross-tool `~/.agents/skills` (Codex, Gemini/Antigravity, OpenCode, Cursor).
*/
const skillDirsOf = (a) => [
path.join(a.claudeHome, "skills"),
path.join(a.home, ".agents", "skills"),
];
const exists = (p) => {
try {
fs.lstatSync(p);
return true;
} catch {
return false;
}
};
/**
* `row => boolean`: is this skill/plugin already in every account the
* viewer's sessions run as (`installAccounts`) — a skill as
* `~/.claude/skills/<name>`, a plugin as a key of `installed_plugins.json`.
* The list shows "Installed" instead of Install for those. Pure reads; an
* account that cannot be resolved or read counts as not installed.
*/
function installedCheck(req) {
let accounts = null;
const plugins = new Map();
const pluginKeys = (a) => {
if (!plugins.has(a.claudeHome)) {
const file = path.join(a.claudeHome, "plugins", "installed_plugins.json");
let keys = new Set();
try {
const text = a.acct
? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 })
: fs.readFileSync(file, "utf8");
keys = new Set(Object.keys(JSON.parse(text)?.plugins || {}));
} catch {
/* none installed, or unreadable */
}
plugins.set(a.claudeHome, keys);
}
return plugins.get(a.claudeHome);
};
return (row) => {
if ((row.kind !== "skill" && row.kind !== "plugin") || !row.source) return false;
if (accounts === null) {
try {
accounts = installAccounts(req);
} catch {
accounts = [];
}
}
if (!accounts.length) return false;
return accounts.every((a) =>
row.kind === "skill"
? exists(path.join(a.claudeHome, "skills", row.source))
: pluginKeys(a).has(row.source)
);
};
}
/**
* Move installed skill folders (`{acct, claudeHome, target}`) aside to
* `~/.claude/agenthub-replaced-skills/<name>[-agents][-uninstalled]-<stamp>`,
* as their account — a replace or an uninstall never deletes a user's copy.
*/
async function moveAside(taken, row, label = "") {
const runAs = require("./run-as");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
for (const t of taken) {
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
const aside = path.join(
t.claudeHome,
"agenthub-replaced-skills",
`${row.source}${tag}${label}-${stamp}`
);
if (t.acct) {
runAs.mkdirAs(t.acct, path.dirname(aside));
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
} else {
fs.mkdirSync(path.dirname(aside), { recursive: true });
fs.renameSync(t.target, aside);
}
}
}
/**
* Install a skill snapshot as `<name>` into every skills folder of every
* account the viewer's sessions run as (`installAccounts` × `skillDirsOf`).
*/
async function installSkill(req, row, { replace = false } = {}) {
const runAs = require("./run-as");
const targets = installAccounts(req).flatMap((a) =>
skillDirsOf(a).map((dir) => ({ ...a, target: path.join(dir, row.source) }))
);
const taken = targets.filter((t) => exists(t.target));
if (taken.length && !replace) {
throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
}
if (taken.some((t) => t.acct) && !runAs.writeAsCanMove()) {
throw fail(
"EUPGRADE",
"replacing needs the updated helper: run `agenthub server install-system` once"
);
}
await moveAside(taken, row);
const files = row.files ? JSON.parse(row.files) : [];
const contents = files.map((f) => ({
rel: f.path.split("/"),
content: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
}));
for (const t of targets) {
for (const f of contents) {
const to = path.join(t.target, ...f.rel);
if (t.acct) {
runAs.mkdirAs(t.acct, path.dirname(to));
runAs.writeFileAs(to, f.content);
} else {
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.writeFileSync(to, f.content);
}
}
}
const paths = targets.map((t) => t.target);
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
}
let execImpl = promisify(require("node:child_process").execFile);
function __setExecImpl(fn) {
execImpl = fn;
}
/** `args => Promise`: runs `claude <args>` as the viewer's own account. */
function claudeAsViewer(req) {
const { acct } = ownAccount(req);
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 });
}
return run;
}
const lastLine = (e) =>
String(e?.stderr || e?.stdout || e?.message || "")
.trim()
.split("\n")
.pop();
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
async function installPlugin(req, row) {
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");
const run = claudeAsViewer(req);
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);
sql(
"INSERT INTO artifact_installs (artifact_id, user_id, paths, replaced) VALUES (?, ?, ?, ?)"
).run(row.id, req?.user?.id ?? null, JSON.stringify(result.paths || []), result.replaced ? 1 : 0);
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "installed");
return result;
}
/**
* Take an installed skill or plugin out of the viewer's own accounts again
* (`installAccounts`). A skill's folders are moved aside like a replace
* (`moveAside`, `-uninstalled`), never deleted; a plugin is `claude plugin
* uninstall --scope user --keep-data`, as the viewer, so its data dir stays.
* Allowed while installs are off — it only removes. `ENOTINSTALLED` when
* there is nothing of it to remove.
*/
async function uninstall(req, id) {
const row = load(req, id);
if (row.kind !== "skill" && row.kind !== "plugin") {
throw fail("EBADKIND", "only a skill or a plugin is uninstalled");
}
let result;
if (row.kind === "skill") {
const runAs = require("./run-as");
const taken = installAccounts(req)
.flatMap((a) => skillDirsOf(a).map((dir) => ({ ...a, target: path.join(dir, row.source) })))
.filter((t) => exists(t.target));
if (!taken.length) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
if (taken.some((t) => t.acct) && !runAs.writeAsCanMove()) {
throw fail(
"EUPGRADE",
"uninstalling needs the updated helper: run `agenthub server install-system` once"
);
}
await moveAside(taken, row, "-uninstalled");
result = { uninstalled: row.source, paths: taken.map((t) => t.target) };
} else {
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
try {
await claudeAsViewer(req)([
"plugin",
"uninstall",
"--scope",
"user",
"--keep-data",
row.source,
]);
} catch (e) {
const msg = lastLine(e);
if (/not (found|installed)/i.test(msg)) {
throw fail("ENOTINSTALLED", `${row.source} is not installed`);
}
throw fail("EPLUGIN", `uninstall: ${msg}`);
}
result = { uninstalled: row.source, paths: [] };
}
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "uninstalled");
return result;
}
/**
* Who installed artifact `id`, newest first: `{items: [{user, mine, at,
* replaced, paths?}], unrecorded}`. The owner, staff and local mode see every
* install; anyone else only their own. `paths` (where a skill was written; a
* plugin has none — it is `claude plugin install --scope user`) goes only to
* the installer and an admin. `unrecorded` = installs counted before this
* list was kept.
*/
function installsOf(req, id) {
const row = load(req, id);
const u = req?.user;
const all = !u || u.role === "admin" || u.role === "analyst" || row.owner_id === u.id;
const rows = sql(
`SELECT i.*, us.username, us.display_name FROM artifact_installs i
LEFT JOIN users us ON us.id = i.user_id
WHERE i.artifact_id = ? ${all ? "" : "AND i.user_id = ?"}
ORDER BY i.id DESC LIMIT 500`
).all(...(all ? [row.id] : [row.id, u.id]));
const items = rows.map((r) => {
const mine = !u || r.user_id === u.id;
let paths = [];
try {
paths = JSON.parse(r.paths) || [];
} catch {
paths = [];
}
return {
user: r.user_id == null ? null : r.display_name || r.username || null,
mine,
at: r.installed_at,
replaced: r.replaced === 1,
...(mine || u?.role === "admin" ? { paths } : {}),
};
});
const total = sql("SELECT COUNT(*) AS n FROM artifact_installs WHERE artifact_id = ?").get(
row.id
).n;
return { items, unrecorded: all ? Math.max(0, (row.installs || 0) - total) : 0 };
}
module.exports = {
KINDS,
installsOf,
DOC_EXT,
list,
load,
view,
canSeeId,
addPage,
captureFromHook,
captureOutputs,
isPageTool,
claim,
decline,
assign,
fileCandidates,
pinFile,
availableSkills,
addSkill,
uploadSkills,
writeSkill,
uploadFile,
skillSlug,
withFrontmatter,
normalizeMarket,
inspectMarket,
addPlugin,
update,
remove,
snapshotFile,
install,
uninstall,
installsEnabled,
setInstallsEnabled,
__setExecImpl,
__setInspectImpl,
};