feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt

- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
+1
View File
@@ -494,6 +494,7 @@ async function testProxy(input = {}) {
}
module.exports = {
proxiesDir,
listProxies,
getProxy,
createProxy,
+782
View File
@@ -0,0 +1,782 @@
/**
* @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/<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 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ĩ <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)", 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
);
}
// ── 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, total } = walkSkill(real);
const runAs = require("./run-as");
const snap = newSnapshotDir();
try {
for (const f of files) {
const target = path.join(snap.dir, ...f.path.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }), {
mode: 0o600,
});
}
} catch (err) {
dropSnapshot(snap.id);
throw err;
}
let summary = "";
try {
const head = fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8");
summary = /^description:\s*(.+)$/m.exec(head)?.[1]?.trim() || "";
} 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: lane ? lane.id : null,
source: name,
snapshot: snap.id,
files: JSON.stringify(files.map((f) => ({ path: f.path, size: f.size }))),
size: total,
}),
req
);
}
// ── 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");
}
}
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");
return view(
insert({
kind: "plugin",
title: cleanTitle(title, k),
description: cleanDescription(description),
owner_id: req?.user ? req.user.id : null,
source: k,
market: validMarket(market),
}),
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));
}
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/<name>`. */
async function installSkill(req, row, { replace = false } = {}) {
const { acct, claudeHome } = ownAccount(req);
const runAs = require("./run-as");
const skills = path.join(claudeHome, "skills");
const target = path.join(skills, row.source);
const taken = (() => {
try {
fs.lstatSync(target);
return true;
} catch {
return false;
}
})();
if (taken && !replace) throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
if (taken) {
const aside = path.join(
claudeHome,
"agenthub-replaced-skills",
`${row.source}-${new Date().toISOString().replace(/[:.]/g, "-")}`
);
if (acct) {
if (!runAs.writeAsCanMove()) {
throw fail("EUPGRADE", "replacing needs the updated helper: run `sudo 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,
addPlugin,
update,
remove,
snapshotFile,
install,
installsEnabled,
setInstallsEnabled,
__setExecImpl,
};
+71 -2
View File
@@ -279,8 +279,8 @@ function stopHostPanes(userId) {
}
}
/** Disable (ending every login) or re-enable a user. Users are never deleted:
* S2 will hang owned rows off their id. */
/** Disable (ending every login) or re-enable a user — the reversible way out;
* `deleteUser` below is the one that removes an account for good. */
function setDisabled(username, disabled) {
const u = requireUser(username);
const linux = require("./linux-accounts");
@@ -298,6 +298,74 @@ function setDisabled(username, disabled) {
if (disabled && hasAccount) linux.provision("disable", u.username);
}
/**
* DELETE a user — the Users page's "Delete user" (an admin, password
* confirmed by the route). In order, so a failure leaves the account usable
* rather than half gone: their logins end and the panes of their workspaces
* stop; under S3 the Linux account `ah-<name>` and its whole home are removed
* (`agenthub-provision delete`; EPROVISION stops here, with no row touched);
* without S3 their own folder under AGENTHUB_USERS_ROOT goes. Then the rows:
* their workspaces' records, prefs, push subscriptions, personal AI Proxy
* profiles and the user. Session history and the audit log stay. Never the
* last enabled admin (ELASTADMIN); the route refuses yourself (ESELF).
* A user made again under the same name gets a NEW, empty Linux account.
*/
function deleteUser(username) {
const u = requireUser(username);
if (u.role === "admin") {
const others = sql(
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
).get(u.id).n;
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
}
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
const pty = require("./pty-run");
for (const lane of lanes) {
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
try {
pty.killRun(pty.runIdForLane(lane.id, slot));
} catch {
/* not running */
}
}
}
const linux = require("./linux-accounts");
if (linux.s3Enabled()) {
// Idempotent: a user with no Linux account (made before S3) or no home
// still passes; anything the helper refuses stops the delete here.
if (linux.LINUX_NAME_RE.test(u.username)) linux.provision("delete", u.username);
} else {
const path = require("node:path");
const fs = require("node:fs");
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
if (root && /^[a-z][a-z0-9_-]{0,63}$/i.test(u.username)) {
const home = path.join(path.resolve(root), u.username);
// Only a real folder directly in the users root — never a link.
const st = fs.lstatSync(home, { throwIfNoEntry: false });
if (st?.isDirectory()) fs.rmSync(home, { recursive: true, force: true });
}
}
const lanesLib = require("./lanes");
for (const lane of lanes) lanesLib.deleteLane(lane.id);
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
try {
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
} catch {
/* an older database without the column */
}
try {
require("node:fs").rmSync(
require("node:path").join(require("./ai-proxies").proxiesDir(), "users", String(u.id)),
{ recursive: true, force: true }
);
} catch {
/* no personal profiles */
}
sql("DELETE FROM users WHERE id = ?").run(u.id);
return { id: u.id, username: u.username, workspaces: lanes.length };
}
/** Give a workspace (a lane id) to a user — for the ones made before server
* mode, which have no owner and so reach no member. */
function assignWorkspace(laneId, username) {
@@ -603,6 +671,7 @@ module.exports = {
setPassword,
setRole,
setDisabled,
deleteUser,
assignWorkspace,
unassignWorkspace,
ownedWorkspaces,
+23 -4
View File
@@ -288,6 +288,17 @@ const userExitStmt = db.prepare(`
LIMIT 1
`);
const anyEndStmt = db.prepare(`
SELECT 1 FROM events WHERE session_id = ? AND event_type = 'SessionEnd' LIMIT 1
`);
// Whether the pane's tmux server was already up when the run started (and is
// still): then the pane ended on its own, not with its server.
function tmuxOutlived(row, serverStartedAt) {
const since = serverStartedAt(row.id);
const started = Date.parse(row.started_at || "");
return Number.isFinite(since) && Number.isFinite(started) && since <= started;
}
// A shell has no session, so its row only tracks the open tab: once closed it
// is dropped — the sidebar keeps no list of closed Terminal tabs.
const pruneShellsStmt = db.prepare(`
@@ -303,12 +314,16 @@ const pruneShellsStmt = db.prepare(`
* A pane AgentHub closed itself (Stop, close tab, Quit) is already `killed`.
* Of the rest, an agent session the user did not exit is `interrupted` — a
* reboot, a killed tmux, a crash — and offered for resume
* (`listInterrupted`); everything else is `abandoned`. Only Claude reports
* how it ended, so any other tool's vanished pane counts as interrupted.
* (`listInterrupted`); everything else is `abandoned`. A pane whose tmux
* server kept running since before it started (`serverStartedAt(id)`, epoch
* ms or null) ended by itself — the user quit Codex, `exit`ed, closed it —
* so it is not offered either: for Claude only when it said it ended (any
* SessionEnd; none at all is a crash), for any other tool, which says
* nothing, always. A server that cannot be asked counts as gone.
*
* Returns the number of rows updated.
*/
function reconcileOrphans(isAlive = () => false) {
function reconcileOrphans(isAlive = () => false, serverStartedAt = () => null) {
try {
let changes = 0;
const { unseenMemberFolder } = require("./linux-accounts");
@@ -317,7 +332,11 @@ function reconcileOrphans(isAlive = () => false) {
const interrupted =
row.tool !== "shell" &&
!!row.session_id &&
!(row.tool === "claude" && userExitStmt.get(row.session_id));
!(row.tool === "claude" && userExitStmt.get(row.session_id)) &&
!(
tmuxOutlived(row, serverStartedAt) &&
(row.tool !== "claude" || anyEndStmt.get(row.session_id))
);
changes += endStmt.run({
id: row.id,
status: interrupted ? "interrupted" : "abandoned",
+89 -26
View File
@@ -5,7 +5,7 @@
*
* Whose identity is decided by an ACCOUNT, never by the request:
* - a member (S3, `run-as.js`): everything lives in their own home, under
* `~/.config/agenthub/` — `ssh/id_ed25519` (+ `.pub`) and `git-credentials`
* `~/.config/agenthub/` — `ssh/id_ed25519` or `ssh/id_rsa` (+ `.pub`) and `git-credentials`
* (git's own `credential-store` format) — written only AS the member
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
* Their git runs as them, so it reads these itself; no secret ever crosses
@@ -61,18 +61,26 @@ function accountForRequest(req) {
return { kind: "host", writable: !user || user.role === "admin" };
}
/**
* The key types offered: ed25519 (the default — GitHub, GitLab, Gitea…) and
* RSA 4096 for hosts that refuse ed25519 (AWS IAM / CodeCommit takes only
* `ssh-rsa`). One key per account at a time, in the file of its type.
*/
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
function paths(account) {
if (account.kind === "member") {
const base = path.join(account.acct.home, ".config", "agenthub");
return {
base,
sshDir: path.join(base, "ssh"),
key: path.join(base, "ssh", "id_ed25519"),
key: path.join(base, "ssh", KEY_FILES.ed25519),
store: path.join(base, "git-credentials"),
};
}
const sshDir = path.join(getClaudeHome(), "agenthub-ssh");
return { base: sshDir, sshDir, key: path.join(sshDir, "id_ed25519"), store: null };
return { base: sshDir, sshDir, key: path.join(sshDir, KEY_FILES.ed25519), store: null };
}
/**
@@ -130,6 +138,31 @@ const u32 = (n) => {
};
const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)]);
/** An SSH mpint: big-endian, with a 0 byte in front when the top bit is set. */
const mpint = (b) => sshString(b[0] & 0x80 ? Buffer.concat([Buffer.from([0]), b]) : b);
/**
* A fresh RSA 4096 pair: the private key as PKCS#1 PEM (`BEGIN RSA PRIVATE
* KEY`, which OpenSSH reads), the public one as an `ssh-rsa` line. Made off
* the event loop — 4096-bit generation takes a second.
*/
async function makeRsaKeyPair(comment) {
const pair = await require("node:util").promisify(crypto.generateKeyPair)("rsa", {
modulusLength: 4096,
publicExponent: 0x10001,
});
const jwk = pair.publicKey.export({ format: "jwk" });
const blob = Buffer.concat([
sshString(Buffer.from("ssh-rsa")),
mpint(Buffer.from(jwk.e, "base64url")),
mpint(Buffer.from(jwk.n, "base64url")),
]);
return {
privateKey: pair.privateKey.export({ type: "pkcs1", format: "pem" }),
publicKey: `ssh-rsa ${blob.toString("base64")} ${comment}\n`,
};
}
/**
* A fresh ed25519 pair in OpenSSH's formats: `{privateKey, publicKey}` text,
* unencrypted (`openssh-key-v1`, cipher none) — the dashboard has no terminal
@@ -175,36 +208,65 @@ function fingerprint(publicKey) {
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
}
/** `{publicKey, fingerprint}` of the account's key, or null — never the private half. */
function sshKeyInfo(account) {
const { key } = paths(account);
const publicKey = readText(account, `${key}.pub`);
if (!publicKey || !/^ssh-ed25519 [A-Za-z0-9+/=]+/.test(publicKey)) return null;
// The private half is only looked at, never read into this process.
if (!isOwnFile(account, key)) return null;
return { publicKey: publicKey.trim(), fingerprint: fingerprint(publicKey) };
/** `{type, file}` of the account's key (ed25519 first), or null. */
function currentKey(account) {
const { sshDir } = paths(account);
for (const type of Object.keys(KEY_FILES)) {
const file = path.join(sshDir, KEY_FILES[type]);
const publicKey = readText(account, `${file}.pub`);
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
// The private half is only looked at, never read into this process.
if (!isOwnFile(account, file)) continue;
return { type, file, publicKey: publicKey.trim() };
}
return null;
}
/** Make the account's key; an existing one is replaced only with `replace`. */
function generateSshKey(account, { replace = false, comment } = {}) {
/** The private key file a clone or fetch uses, or null. */
const keyFile = (account) => currentKey(account)?.file ?? null;
/** `{type, publicKey, fingerprint}` of the account's key, or null — never the private half. */
function sshKeyInfo(account) {
const k = currentKey(account);
return k ? { type: k.type, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey) } : null;
}
/**
* Make the account's key (`type`: ed25519, the default, or rsa); an existing
* one is replaced only with `replace` — and a key of the other type goes, so
* there is one key per account.
*/
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
if (sshKeyInfo(account) && !replace) {
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
}
const label = String(comment || "agenthub")
.replace(/[^\w.@+-]/g, "")
.slice(0, 64);
const { privateKey, publicKey } = makeKeyPair(label || "agenthub");
const { key } = paths(account);
writeText(account, key, privateKey, { secret: true });
writeText(account, `${key}.pub`, publicKey, { secret: false });
const label =
String(comment || "agenthub")
.replace(/[^\w.@+-]/g, "")
.slice(0, 64) || "agenthub";
const { privateKey, publicKey } =
type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label);
const { sshDir } = paths(account);
const file = path.join(sshDir, KEY_FILES[type]);
writeText(account, file, privateKey, { secret: true });
writeText(account, `${file}.pub`, publicKey, { secret: false });
for (const [other, name] of Object.entries(KEY_FILES)) {
if (other === type) continue;
const f = path.join(sshDir, name);
if (isOwnFile(account, f) || readText(account, `${f}.pub`) != null) {
removeFile(account, f);
removeFile(account, `${f}.pub`);
}
}
return sshKeyInfo(account);
}
function removeSshKey(account) {
const { key } = paths(account);
if (!sshKeyInfo(account)) return false;
removeFile(account, key);
removeFile(account, `${key}.pub`);
const k = currentKey(account);
if (!k) return false;
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
return true;
}
@@ -354,7 +416,7 @@ async function sshConfigFor(dir) {
} catch {
return [];
}
const key = sshKeyInfo(account) ? paths(account).key : null;
const key = keyFile(account);
return [
"-c",
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
@@ -464,7 +526,7 @@ async function cloneRepo({ url, parent, name, once = null }) {
if (remote.scheme === "ssh") {
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
const key = sshKeyInfo(account) ? paths(account).key : null;
const key = keyFile(account);
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails.
@@ -552,6 +614,7 @@ module.exports = {
accountForDir,
accountForRequest,
makeKeyPair,
makeRsaKeyPair,
fingerprint,
sshKeyInfo,
generateSshKey,
+9 -3
View File
@@ -23,7 +23,7 @@ const { execFileSync } = require("node:child_process");
/** Lower case, a letter first, ≤ 29 so `ah-<name>` fits Linux's 32. */
const LINUX_NAME_RE = /^[a-z][a-z0-9_-]{0,28}$/;
const PROVISION = "/opt/agenthub/sbin/agenthub-provision";
const ACTIONS = new Set(["create", "disable", "enable"]);
const ACTIONS = new Set(["create", "disable", "enable", "delete"]);
const confPath = () => process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server.conf";
@@ -121,7 +121,8 @@ function __setRunImpl(fn) {
}
/**
* Create / disable / enable the Linux account of `username`. A no-op outside
* Create / disable / enable / delete (account AND home) the Linux account of
* `username`. A no-op outside
* S3 (returns null). Throws EPROVISION with the helper's own words on failure.
*/
function provision(action, username) {
@@ -135,7 +136,12 @@ function provision(action, username) {
.trim()
.split("\n")
.pop();
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}`), {
// An installed helper older than this build (root's copy is refreshed
// only by install-system) does not know the action yet.
const hint = /unknown action/.test(why)
? " — the server helper is older than this AgentHub: run `sudo agenthub server install-system` once"
: "";
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}${hint}`), {
code: "EPROVISION",
});
}
+8
View File
@@ -154,6 +154,11 @@ const PATH_RULES = [
[/^\/workflows\/session\/([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
[/^\/workflows\/runs\/([^/]+)/i, (req, m) => canSeeRowSession(req, "workflows", m[1], "run_id")],
[/^\/alerts\/([^/]+)\/ack/i, (req, m) => canSeeRowSession(req, "alert_events", m[1])],
// `/artifacts/<id>…`: the owner's, or a shared one (server/lib/artifacts.js).
[
/^\/artifacts\/(\d+)(?:\/|$)/i,
(req, m) => require("./artifacts").canSeeId(req, m[1]),
],
[
/^\/snapshots\/([^/]+)$/i,
(req, m) => req.method !== "DELETE" || canSeeRowSession(req, "session_snapshots", m[1]),
@@ -296,6 +301,9 @@ function broadcastAudience(type, data) {
case "lane_hook_result":
case "lane_runtime":
return { owners: laneOwners(d.laneId) };
// A shared artifact is everyone's news; a private one, its owner's.
case "artifact_changed":
return d.shared ? null : { owners: new Set(d.ownerId != null ? [d.ownerId] : []) };
case "update_status":
// No payload: each client refetches its own `GET /api/run/interrupted`.
case "runs_interrupted":
+17
View File
@@ -386,6 +386,22 @@ function serverPid() {
}
}
/**
* When the tmux server that holds (or held) session `name` started, in epoch
* ms — the host's, or the member's under S3 — or null when there is no such
* server or it cannot be asked. A pane gone while its server ran on since
* before the pane began exited by itself; one whose server is gone or newer
* died with it (a reboot, a killed tmux).
*/
function serverStartedAt(name) {
try {
const out = String(onServerOf(name, ["display-message", "-p", "#{start_time}"])).trim();
return /^\d+$/.test(out) ? Number(out) * 1000 : null;
} catch {
return null;
}
}
/** `-e NAME=value` pairs for tmux, in a stable order. */
function envFlags(env) {
const flags = [];
@@ -703,6 +719,7 @@ module.exports = {
capturePane,
sendKey,
killSession,
serverStartedAt,
listSessions,
liveSessions,
isTmuxAvailable,