chore: v2.8.7 — Git SSH keys in tabs, safer Artifacts preview and search

- Settings → Git → Your git identity: SSH keys are one tab per host kind
  (GitHub · GitLab, AWS CodeCommit, More keys, Other hosts); opens on the
  AWS tab when only an RSA key exists.
- Artifacts: a file type the sandboxed preview cannot show (Office, zip,
  PDF) offers Download only, never a blank Preview.
- Artifacts search: `%`/`_`/`\` in a query match literally (LIKE ... ESCAPE).
- Skill frontmatter rewrite replaces a key with its continuation lines and
  quotes values YAML could misread.
- Plugin marketplace.json is read with a 1 MB streaming cap.
- Artifact lists look each owner up once.
This commit is contained in:
2026-10-15 17:17:47 +07:00
parent 4d6067650a
commit a58e95a957
16 changed files with 763 additions and 380 deletions
+177 -60
View File
@@ -57,7 +57,8 @@ const KINDS = ["page", "file", "skill", "plugin"];
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 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}$/;
@@ -86,7 +87,8 @@ const canSee = (req, row) => {
};
/** 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;
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) {
@@ -101,12 +103,18 @@ function load(req, id) {
return row;
}
function view(row, req) {
function view(row, req, owners = null) {
if (!row) return null;
const owner =
row.owner_id == null
? null
: sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id);
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,
@@ -159,15 +167,22 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
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}`)}%`;
// `\` 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);
return rows.map((r) => view(r, req));
const owners = new Map();
return rows.map((r) => view(r, req, owners));
}
// ── Small helpers ─────────────────────────────────────────────────────────
@@ -176,8 +191,7 @@ 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) : "";
const cleanDescription = (v) => (typeof v === "string" ? v.trim().slice(0, DESCRIPTION_MAX) : "");
function insert(fields) {
const cols = Object.keys(fields);
@@ -215,9 +229,9 @@ function sessionOwner(sessionId) {
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)
);
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;
}
@@ -235,9 +249,10 @@ function validPageUrl(url) {
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);
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 = ?"
@@ -272,9 +287,10 @@ function captureFromHook(sessionId, data) {
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);
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({
@@ -370,7 +386,7 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
kind: "file",
title: cleanTitle(title, name),
description: cleanDescription(description),
owner_id: req?.user ? req.user.id : lane.owner_id ?? null,
owner_id: req?.user ? req.user.id : (lane.owner_id ?? null),
lane_id: lane.id,
session_id: sessionId ? String(sessionId) : null,
source: relPosix,
@@ -391,7 +407,11 @@ function uploadFile(req, { name, data, title, description }) {
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 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 });
@@ -530,7 +550,8 @@ function addSkill(req, { name, laneId, title, description }) {
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");
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, {
@@ -577,7 +598,10 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
}
let summary = "";
try {
summary = frontmatterField(fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"), "description");
summary = frontmatterField(
fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"),
"description"
);
} catch {
/* no frontmatter */
}
@@ -624,7 +648,10 @@ function skillSlug(text) {
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)");
throw fail(
"EBADNAME",
"skill name: letters, digits, '.', '_' or '-' (it becomes the folder and the /command)"
);
}
return name;
}
@@ -638,8 +665,10 @@ function cleanUploadPath(raw) {
// 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");
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("/");
}
@@ -684,7 +713,9 @@ function skillsFromUpload(uploads, { name, description }) {
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 })));
files.push(
...unzipSkill(u.data).map((f) => ({ path: cleanUploadPath(f.path), data: f.data }))
);
} else {
files.push({ path: p, data: u.data });
}
@@ -760,8 +791,16 @@ function checkSkillFiles(files) {
function withFrontmatter(text, name, description) {
const body = String(text).replace(/^\uFEFF/, "");
const fm = /^---\r?\n([\s\S]*?)\r?\n---[^\n]*(\r?\n|$)/.exec(body);
const desc = String(description || "").trim().replace(/\s+/g, " ");
const quote = (v) => (/^[\w .,()/-]*$/.test(v) && !/^[\d.]+$/.test(v) ? v : JSON.stringify(v));
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(
@@ -773,12 +812,28 @@ function withFrontmatter(text, name, description) {
}
let lines = fm[1].split(/\r?\n/);
const has = (k) => lines.some((l) => new RegExp(`^${k}:`).test(l));
lines = has("name")
? lines.map((l) => (/^name:/.test(l) ? `name: ${name}` : l))
: [`name: ${name}`, ...lines];
// 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")
? lines.map((l) => (/^description:/.test(l) ? `description: ${quote(desc)}` : l))
? setKey("description", quote(desc))
: [...lines, `description: ${quote(desc)}`];
} else if (!frontmatterField(body, "description")) {
throw fail(
@@ -835,7 +890,8 @@ 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");
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") }],
@@ -889,15 +945,37 @@ async function fetchMarketJson(market) {
if (/^https:\/\/.*\.json$/i.test(market)) {
let res;
try {
res = await fetch(market, { signal: AbortSignal.timeout(MARKET_TIMEOUT_MS), redirect: "follow" });
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.url.startsWith("https://"))
throw fail("EMARKETFETCH", "the link redirected off https");
if (!res.ok) throw fail("EMARKETFETCH", `${market} answered ${res.status}`);
const text = await res.text();
if (text.length > MARKET_JSON_MAX) throw fail("ETOOBIG", "marketplace.json is larger than 1 MB");
return text;
// 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) {
@@ -912,32 +990,59 @@ async function fetchMarketJson(market) {
// 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",
"-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,
});
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)) {
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");
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(
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
);
@@ -983,7 +1088,12 @@ async function inspectMarket(raw) {
version: str(p.version, 50),
category: str(p.category, 50),
}));
return { market, name, description: str(json.description || json.metadata?.description), plugins };
return {
market,
name,
description: str(json.description || json.metadata?.description),
plugins,
};
}
function __setInspectImpl(fn) {
@@ -999,10 +1109,9 @@ function addPlugin(req, { key, market, title, description }) {
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
);
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 = ?,
@@ -1102,7 +1211,12 @@ function snapshotFile(req, id, rel) {
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 };
return {
row,
abs: path.join(root(), row.snapshot, ...hit.path.split("/")),
name: path.basename(hit.path),
size: hit.size,
};
}
// ── install ───────────────────────────────────────────────────────────────
@@ -1146,7 +1260,10 @@ async function installSkill(req, row, { replace = false } = {}) {
);
if (acct) {
if (!runAs.writeAsCanMove()) {
throw fail("EUPGRADE", "replacing needs the updated helper: run `agenthub server install-system` once");
throw fail(
"EUPGRADE",
"replacing needs the updated helper: run `agenthub server install-system` once"
);
}
runAs.mkdirAs(acct, path.dirname(aside));
await runAs.writeAsAsync(acct, target, ["move", aside]);