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:
@@ -42,19 +42,21 @@ function request(method, p, body, headers = {}) {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
text: await r.text(),
|
||||
})).then((r) => {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(r.text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return { ...r, body: json };
|
||||
});
|
||||
})
|
||||
.then(async (r) => ({
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
text: await r.text(),
|
||||
}))
|
||||
.then((r) => {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(r.text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return { ...r, body: json };
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
@@ -63,18 +65,23 @@ before(async () => {
|
||||
fs.writeFileSync(path.join(ws, "main.js"), "code");
|
||||
const skill = path.join(ws, ".claude", "skills", "deploy");
|
||||
fs.mkdirSync(path.join(skill, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(skill, "SKILL.md"), "---\nname: deploy\ndescription: Ship it\n---\nDo it.\n");
|
||||
fs.writeFileSync(
|
||||
path.join(skill, "SKILL.md"),
|
||||
"---\nname: deploy\ndescription: Ship it\n---\nDo it.\n"
|
||||
);
|
||||
fs.writeFileSync(path.join(skill, "scripts", "run.sh"), "echo hi\n");
|
||||
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
|
||||
lane = lanesLib.createLane({ cwd: ws, title: "ws" });
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)").run(
|
||||
ws,
|
||||
lane.id
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)"
|
||||
).run(ws, lane.id);
|
||||
const ev = db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES ('s1', 'PostToolUse', ?, ?)"
|
||||
);
|
||||
ev.run("Write", JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } }));
|
||||
ev.run(
|
||||
"Write",
|
||||
JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } })
|
||||
);
|
||||
ev.run("Edit", JSON.stringify({ tool_input: { file_path: path.join(ws, "main.js") } }));
|
||||
ev.run("Write", JSON.stringify({ tool_input: { file_path: "/etc/passwd.md" } }));
|
||||
server = http.createServer(createApp());
|
||||
@@ -125,7 +132,10 @@ describe("files", () => {
|
||||
});
|
||||
|
||||
it("pins a snapshot and serves it sandboxed, never a path outside it", async () => {
|
||||
const r = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "out/report.html" });
|
||||
const r = await request("POST", "/api/artifacts/file", {
|
||||
laneId: lane.id,
|
||||
path: "out/report.html",
|
||||
});
|
||||
assert.equal(r.status, 201, r.text);
|
||||
const id = r.body.item.id;
|
||||
// The live file changes; the artifact does not.
|
||||
@@ -138,7 +148,10 @@ describe("files", () => {
|
||||
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
|
||||
assert.equal(other.status, 404);
|
||||
|
||||
const escape = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "../t.db" });
|
||||
const escape = await request("POST", "/api/artifacts/file", {
|
||||
laneId: lane.id,
|
||||
path: "../t.db",
|
||||
});
|
||||
assert.equal(escape.status, 400);
|
||||
|
||||
const del = await request("DELETE", `/api/artifacts/${id}`);
|
||||
@@ -154,10 +167,7 @@ describe("skills", () => {
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
|
||||
assert.equal(r.status, 201, r.text);
|
||||
assert.equal(r.body.item.description, "Ship it");
|
||||
assert.deepEqual(
|
||||
r.body.item.files.map((f) => f.path).sort(),
|
||||
["SKILL.md", "scripts/run.sh"]
|
||||
);
|
||||
assert.deepEqual(r.body.item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
|
||||
const id = r.body.item.id;
|
||||
|
||||
const first = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||
@@ -240,7 +250,12 @@ function upload(p, files, fields = {}) {
|
||||
|
||||
const snapText = (item, file = "SKILL.md") =>
|
||||
fs.readFileSync(
|
||||
path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot, file),
|
||||
path.join(
|
||||
process.env.DASHBOARD_DATA_DIR,
|
||||
"artifacts",
|
||||
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot,
|
||||
file
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
@@ -263,7 +278,10 @@ describe("plugins from a marketplace link", () => {
|
||||
n("https://git.example.vn/Org/skills/src/branch/main/"),
|
||||
"https://git.example.vn/Org/skills"
|
||||
);
|
||||
assert.equal(n("https://git.example.vn/Org/skills.git"), "https://git.example.vn/Org/skills.git");
|
||||
assert.equal(
|
||||
n("https://git.example.vn/Org/skills.git"),
|
||||
"https://git.example.vn/Org/skills.git"
|
||||
);
|
||||
assert.throws(() => n("file:///etc"), { code: "EBADMARKET" });
|
||||
assert.throws(() => n("http://x/y.git"), { code: "EBADMARKET" });
|
||||
});
|
||||
@@ -281,7 +299,10 @@ describe("plugins from a marketplace link", () => {
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(asked, "https://git.example.vn/Org/skills.git");
|
||||
assert.equal(r.body.name, "sg");
|
||||
assert.deepEqual(r.body.plugins.map((p) => p.key), ["sg-dev@sg", "sg-common@sg"]);
|
||||
assert.deepEqual(
|
||||
r.body.plugins.map((p) => p.key),
|
||||
["sg-dev@sg", "sg-common@sg"]
|
||||
);
|
||||
|
||||
const add = (title) =>
|
||||
request("POST", "/api/artifacts/plugin", { key: "sg-dev@sg", market: r.body.market, title });
|
||||
@@ -312,14 +333,21 @@ describe("plugins from a marketplace link", () => {
|
||||
|
||||
describe("skills from files or written", () => {
|
||||
it("makes one Markdown file the SKILL.md, named by the user, with a frontmatter", async () => {
|
||||
const r = await upload("/api/artifacts/skill/upload", [["Notes cho đội.md", "# Do this\nStep 1\n"]], {
|
||||
name: "Hướng dẫn deploy",
|
||||
description: "Use when deploying",
|
||||
});
|
||||
const r = await upload(
|
||||
"/api/artifacts/skill/upload",
|
||||
[["Notes cho đội.md", "# Do this\nStep 1\n"]],
|
||||
{
|
||||
name: "Hướng dẫn deploy",
|
||||
description: "Use when deploying",
|
||||
}
|
||||
);
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const [item] = r.body.items;
|
||||
assert.equal(item.source, "Huong-dan-deploy");
|
||||
assert.deepEqual(item.files.map((f) => f.path), ["SKILL.md"]);
|
||||
assert.deepEqual(
|
||||
item.files.map((f) => f.path),
|
||||
["SKILL.md"]
|
||||
);
|
||||
const text = snapText(item);
|
||||
assert.match(text, /^---\nname: Huong-dan-deploy\ndescription: Use when deploying\n---\n/);
|
||||
assert.match(text, /# Do this/);
|
||||
@@ -364,15 +392,23 @@ describe("skills from files or written", () => {
|
||||
["b/SKILL.md", "no frontmatter"],
|
||||
]);
|
||||
assert.equal(r.body.error.code, "ENODESCRIPTION");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n, before);
|
||||
const secret = await upload(
|
||||
"/api/artifacts/skill/upload",
|
||||
[["s/SKILL.md", "---\ndescription: S\n---\n"], ["s/.env", "K=1"]]
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n,
|
||||
before
|
||||
);
|
||||
const secret = await upload("/api/artifacts/skill/upload", [
|
||||
["s/SKILL.md", "---\ndescription: S\n---\n"],
|
||||
["s/.env", "K=1"],
|
||||
]);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], { description: "d" });
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], {
|
||||
description: "d",
|
||||
});
|
||||
assert.equal(out.body.error.code, "EBADPATH");
|
||||
const none = await upload("/api/artifacts/skill/upload", [["a.png", "x"], ["b.png", "y"]]);
|
||||
const none = await upload("/api/artifacts/skill/upload", [
|
||||
["a.png", "x"],
|
||||
["b.png", "y"],
|
||||
]);
|
||||
assert.equal(none.body.error.code, "ENOSKILLMD");
|
||||
});
|
||||
|
||||
@@ -383,13 +419,22 @@ describe("skills from files or written", () => {
|
||||
content: "Summarise.",
|
||||
});
|
||||
assert.equal(r.status, 201, r.text);
|
||||
assert.match(snapText(r.body.item), /^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./);
|
||||
const bad = await request("POST", "/api/artifacts/skill/new", { name: "!!!", description: "d", content: "c" });
|
||||
assert.match(
|
||||
snapText(r.body.item),
|
||||
/^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./
|
||||
);
|
||||
const bad = await request("POST", "/api/artifacts/skill/new", {
|
||||
name: "!!!",
|
||||
description: "d",
|
||||
content: "c",
|
||||
});
|
||||
assert.equal(bad.body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
|
||||
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], { title: "Report" });
|
||||
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
|
||||
title: "Report",
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
assert.equal(r.body.item.kind, "file");
|
||||
assert.equal(r.body.item.title, "Report");
|
||||
@@ -408,13 +453,25 @@ describe("sharing and visibility", () => {
|
||||
});
|
||||
|
||||
it("shows a server-mode developer their own rows and shared ones only", () => {
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')").run();
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)").run();
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')").run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')"
|
||||
).run();
|
||||
const dev = { method: "GET", user: { id: 7, role: "developer", username: "d" } };
|
||||
const titles = artifacts.list(dev).map((i) => i.title).sort();
|
||||
const titles = artifacts
|
||||
.list(dev)
|
||||
.map((i) => i.title)
|
||||
.sort();
|
||||
assert.deepEqual(titles, ["a", "b"]);
|
||||
assert.deepEqual(artifacts.list(dev, { scope: "hub" }).map((i) => i.title), ["b"]);
|
||||
assert.deepEqual(
|
||||
artifacts.list(dev, { scope: "hub" }).map((i) => i.title),
|
||||
["b"]
|
||||
);
|
||||
const hidden = db.prepare("SELECT id FROM artifacts WHERE title = 'c'").get().id;
|
||||
assert.equal(artifacts.canSeeId(dev, hidden), false);
|
||||
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
|
||||
@@ -431,3 +488,67 @@ describe("sharing and visibility", () => {
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search, frontmatter and marketplace limits", () => {
|
||||
it("finds a title holding `_` or `%` literally, and only that one", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'my_skill', 'https://claude.ai/u1')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'myXskill', 'https://claude.ai/u2')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, url) VALUES ('page', '100% done', 'https://claude.ai/u3')"
|
||||
).run();
|
||||
assert.deepEqual(
|
||||
artifacts.list(null, { q: "my_skill" }).map((i) => i.title),
|
||||
["my_skill"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
artifacts.list(null, { q: "100%" }).map((i) => i.title),
|
||||
["100% done"]
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a multi-line description whole, and quotes one YAML would misread", () => {
|
||||
const src =
|
||||
"---\nname: x\ndescription: >\n old line one\n old line two\nallowed-tools: Read\n---\nbody\n";
|
||||
assert.equal(
|
||||
artifacts.withFrontmatter(src, "x", "new desc"),
|
||||
"---\nname: x\ndescription: new desc\nallowed-tools: Read\n---\nbody\n"
|
||||
);
|
||||
assert.match(
|
||||
artifacts.withFrontmatter("body", "x", "- Use for reports"),
|
||||
/^description: "- Use for reports"$/m
|
||||
);
|
||||
assert.match(
|
||||
artifacts.withFrontmatter("body", "x", "Use for reports"),
|
||||
/^description: Use for reports$/m
|
||||
);
|
||||
});
|
||||
|
||||
it("stops reading a marketplace.json link past 1 MB", async () => {
|
||||
const real = globalThis.fetch;
|
||||
let pulled = 0;
|
||||
globalThis.fetch = async () => {
|
||||
const body = new ReadableStream({
|
||||
pull(c) {
|
||||
pulled += 1;
|
||||
if (pulled > 100) return c.close();
|
||||
c.enqueue(new Uint8Array(64 * 1024));
|
||||
},
|
||||
});
|
||||
const res = new Response(body);
|
||||
Object.defineProperty(res, "url", { value: "https://x.example/m.json" });
|
||||
return res;
|
||||
};
|
||||
try {
|
||||
await assert.rejects(artifacts.inspectMarket("https://x.example/m.json"), {
|
||||
code: "ETOOBIG",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = real;
|
||||
}
|
||||
assert.ok(pulled < 30, `read ${pulled} chunks`);
|
||||
});
|
||||
});
|
||||
|
||||
+177
-60
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user