dsh-skill-hub 0.3.27 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +1 -1
- package/README.md +10 -2
- package/README.zh.md +10 -2
- package/lib/client.js +310 -39
- package/lib/client.js.map +1 -1
- package/lib/index.js +790 -15
- package/lib/types/client/api.d.ts +8 -1
- package/lib/types/client/locales/common.d.ts +4 -0
- package/lib/types/client/locales/market.d.ts +10 -0
- package/lib/types/client/locales/sources.d.ts +3 -0
- package/lib/types/client/locales.d.ts +17 -0
- package/lib/types/client/panel/ForeignImportDialog.d.ts +15 -0
- package/lib/types/client/panel/PanelDialogs.d.ts +2 -0
- package/lib/types/client/panel/dialogs.d.ts +11 -1
- package/lib/types/client/panel/useSkillHub.d.ts +1 -0
- package/lib/types/protocol/api.d.ts +2 -0
- package/lib/types/protocol/catalog.d.ts +39 -1
- package/lib/types/protocol/foreign.d.ts +51 -0
- package/lib/types/protocol/sources.d.ts +13 -0
- package/lib/types/protocol.d.ts +3 -1
- package/lib/types/repo/install.d.ts +4 -0
- package/lib/types/routes/deps.d.ts +11 -0
- package/lib/types/routes/foreign.d.ts +10 -0
- package/lib/types/routes/helpers.d.ts +1 -1
- package/lib/types/skillfs/audit.d.ts +58 -0
- package/lib/types/skillfs/foreign.d.ts +51 -0
- package/lib/types/skillfs/hash.d.ts +24 -0
- package/lib/types/skillfs.d.ts +2 -0
- package/lib/types/store/store.d.ts +6 -0
- package/package.json +2 -2
- package/src/client/api.ts +14 -0
- package/src/client/locales/common.ts +8 -0
- package/src/client/locales/market.ts +20 -0
- package/src/client/locales/sources.ts +6 -0
- package/src/client/panel/ForeignImportDialog.tsx +130 -0
- package/src/client/panel/PanelDialogs.tsx +4 -1
- package/src/client/panel/SkillHubPanel.tsx +34 -4
- package/src/client/panel/SourceStatusBadge.tsx +23 -13
- package/src/client/panel/dialogs.tsx +14 -3
- package/src/client/panel/panel.module.css +6 -0
- package/src/client/panel/useSkillHub.ts +1 -1
- package/src/index.ts +2 -2
- package/src/protocol/api.ts +2 -0
- package/src/protocol/catalog.ts +56 -0
- package/src/protocol/foreign.ts +47 -0
- package/src/protocol/sources.ts +13 -0
- package/src/protocol.test.ts +59 -0
- package/src/protocol.ts +3 -0
- package/src/repo/install.ts +34 -0
- package/src/repo.test.ts +55 -0
- package/src/routes/catalog-data.ts +2 -0
- package/src/routes/deps.ts +15 -0
- package/src/routes/foreign.ts +127 -0
- package/src/routes/helpers.ts +1 -0
- package/src/routes/repo-import.ts +4 -1
- package/src/routes/sources.ts +53 -3
- package/src/routes.test.ts +162 -3
- package/src/routes.ts +2 -0
- package/src/skillfs/audit.test.ts +182 -0
- package/src/skillfs/audit.ts +229 -0
- package/src/skillfs/foreign.test.ts +121 -0
- package/src/skillfs/foreign.ts +198 -0
- package/src/skillfs/hash.test.ts +129 -0
- package/src/skillfs/hash.ts +112 -0
- package/src/skillfs/scan.ts +17 -0
- package/src/skillfs.test.ts +37 -0
- package/src/skillfs.ts +2 -0
- package/src/store/migrate.ts +7 -1
- package/src/store/store.ts +36 -3
- package/src/store.test.ts +24 -0
package/lib/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
-
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { isSkillName } from "@deepseek-ai/dsh-skill";
|
|
7
7
|
import { dump, load } from "js-yaml";
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
//#region src/protocol/api.ts
|
|
10
10
|
/**
|
|
11
11
|
* Root path of the skill-hub API family. The host also registers this as its
|
|
@@ -56,9 +56,40 @@ const SKILL_HUB_API = {
|
|
|
56
56
|
sourceTrashClear: "/api/skill-hub/sources/trash/clear",
|
|
57
57
|
diagnosticFix: "/api/skill-hub/diagnostic/fix",
|
|
58
58
|
marketSourceVersions: "/api/skill-hub/market/source/versions",
|
|
59
|
-
marketStats: "/api/skill-hub/market/stats"
|
|
59
|
+
marketStats: "/api/skill-hub/market/stats",
|
|
60
|
+
foreign: "/api/skill-hub/foreign",
|
|
61
|
+
foreignImport: "/api/skill-hub/foreign/import"
|
|
60
62
|
};
|
|
61
63
|
//#endregion
|
|
64
|
+
//#region src/protocol/catalog.ts
|
|
65
|
+
/**
|
|
66
|
+
* 技能目录的建议字符预算。取 8000:这是 Codex 在上下文窗口未知时对初始
|
|
67
|
+
* 技能清单的硬上限,也是目前公开客户端里最保守的一档。超出后尾部条目会被
|
|
68
|
+
* 静默丢弃(技能看起来装了,实际不触发)。
|
|
69
|
+
*/
|
|
70
|
+
const ANNOUNCEMENT_CHAR_BUDGET = 8e3;
|
|
71
|
+
/**
|
|
72
|
+
* 按「已启用且模型可调用」的技能估算公告成本。只统计一层渐进加载真正注入的
|
|
73
|
+
* 字段(name + description + whenToUse),不含正文 —— 正文是激活后才载入的,
|
|
74
|
+
* 不占常驻预算。
|
|
75
|
+
*/
|
|
76
|
+
function computeAnnouncementBudget(skills) {
|
|
77
|
+
let chars = 0;
|
|
78
|
+
let count = 0;
|
|
79
|
+
for (const skill of skills) {
|
|
80
|
+
if (!skill.invocation.modelInvocable) continue;
|
|
81
|
+
count += 1;
|
|
82
|
+
chars += skill.name.length + skill.description.length + (skill.whenToUse?.length ?? 0);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
skills: count,
|
|
86
|
+
chars,
|
|
87
|
+
approxTokens: Math.ceil(chars / 3),
|
|
88
|
+
budget: ANNOUNCEMENT_CHAR_BUDGET,
|
|
89
|
+
overBudget: chars > ANNOUNCEMENT_CHAR_BUDGET
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
62
93
|
//#region src/protocol/config.ts
|
|
63
94
|
/**
|
|
64
95
|
* This plugin's Loader entry id — the settings namespace dsh serves its config
|
|
@@ -313,6 +344,7 @@ function hydrateMigratedState(migrated) {
|
|
|
313
344
|
const source = entry;
|
|
314
345
|
if (source !== null && typeof source === "object" && typeof source.repo === "string" && source.repo !== "" && Array.isArray(source.skills)) {
|
|
315
346
|
const manifest = source.manifest;
|
|
347
|
+
const localHashes = source.localHashes;
|
|
316
348
|
const rawRoot = isValidRepoRoot(source.root) ? source.root : "skills";
|
|
317
349
|
sourcesByRepo.set(source.repo, {
|
|
318
350
|
repo: source.repo,
|
|
@@ -320,7 +352,8 @@ function hydrateMigratedState(migrated) {
|
|
|
320
352
|
root: rawRoot,
|
|
321
353
|
commitSha: typeof source.commitSha === "string" ? source.commitSha : "",
|
|
322
354
|
skills: source.skills.filter((n) => typeof n === "string"),
|
|
323
|
-
...manifest !== null && typeof manifest === "object" && !Array.isArray(manifest) ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === "number")) } : {}
|
|
355
|
+
...manifest !== null && typeof manifest === "object" && !Array.isArray(manifest) ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === "number")) } : {},
|
|
356
|
+
...localHashes !== null && typeof localHashes === "object" && !Array.isArray(localHashes) ? { localHashes: Object.fromEntries(Object.entries(localHashes).filter(([, hash]) => typeof hash === "string" && hash !== "")) } : {}
|
|
324
357
|
});
|
|
325
358
|
}
|
|
326
359
|
}
|
|
@@ -978,10 +1011,22 @@ var SkillHubStore = class {
|
|
|
978
1011
|
if (existing === void 0) return void 0;
|
|
979
1012
|
const names = [...new Set(skills.filter((n) => n.trim() !== ""))].sort((a, b) => a.localeCompare(b));
|
|
980
1013
|
if (names.length === 0) this.sourcesByRepo.delete(repo);
|
|
981
|
-
else
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1014
|
+
else {
|
|
1015
|
+
const next = {
|
|
1016
|
+
...existing,
|
|
1017
|
+
skills: names
|
|
1018
|
+
};
|
|
1019
|
+
if (existing.localHashes !== void 0) {
|
|
1020
|
+
const localHashes = {};
|
|
1021
|
+
for (const name of names) {
|
|
1022
|
+
const hash = existing.localHashes[name];
|
|
1023
|
+
if (hash !== void 0) localHashes[name] = hash;
|
|
1024
|
+
}
|
|
1025
|
+
if (Object.keys(localHashes).length > 0) next.localHashes = localHashes;
|
|
1026
|
+
else delete next.localHashes;
|
|
1027
|
+
}
|
|
1028
|
+
this.sourcesByRepo.set(repo, next);
|
|
1029
|
+
}
|
|
985
1030
|
await this.persist();
|
|
986
1031
|
return this.sourcesByRepo.get(repo);
|
|
987
1032
|
}
|
|
@@ -993,10 +1038,17 @@ var SkillHubStore = class {
|
|
|
993
1038
|
if (!source.skills.includes(name)) continue;
|
|
994
1039
|
const skills = source.skills.filter((n) => n !== name);
|
|
995
1040
|
if (skills.length === 0) this.sourcesByRepo.delete(repo);
|
|
996
|
-
else
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1041
|
+
else {
|
|
1042
|
+
const localHashes = {};
|
|
1043
|
+
for (const [key, value] of Object.entries(source.localHashes ?? {})) if (key !== name) localHashes[key] = value;
|
|
1044
|
+
const next = {
|
|
1045
|
+
...source,
|
|
1046
|
+
skills
|
|
1047
|
+
};
|
|
1048
|
+
if (Object.keys(localHashes).length > 0) next.localHashes = localHashes;
|
|
1049
|
+
else delete next.localHashes;
|
|
1050
|
+
this.sourcesByRepo.set(repo, next);
|
|
1051
|
+
}
|
|
1000
1052
|
changed = true;
|
|
1001
1053
|
}
|
|
1002
1054
|
if (changed) await this.persist();
|
|
@@ -1012,6 +1064,24 @@ var SkillHubStore = class {
|
|
|
1012
1064
|
});
|
|
1013
1065
|
await this.persist();
|
|
1014
1066
|
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Record one skill's local content hash (sampled right after the bytes land).
|
|
1069
|
+
* This is the only thing that can tell "the user edited this" apart from
|
|
1070
|
+
* "upstream moved", so it is written on every import and every sync.
|
|
1071
|
+
*/
|
|
1072
|
+
async setSourceSkillHash(repo, skillName, hash) {
|
|
1073
|
+
await this.ensureLoaded();
|
|
1074
|
+
const existing = this.sourcesByRepo.get(repo);
|
|
1075
|
+
if (existing === void 0 || hash === "") return;
|
|
1076
|
+
this.sourcesByRepo.set(repo, {
|
|
1077
|
+
...existing,
|
|
1078
|
+
localHashes: {
|
|
1079
|
+
...existing.localHashes ?? {},
|
|
1080
|
+
[skillName]: hash
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
await this.persist();
|
|
1084
|
+
}
|
|
1015
1085
|
/** Update a source's pinned ref (release tag / branch) when the market syncs. */
|
|
1016
1086
|
async setSourceRef(repo, ref) {
|
|
1017
1087
|
await this.ensureLoaded();
|
|
@@ -1399,6 +1469,199 @@ function rootOfPath(path, home = dshHome()) {
|
|
|
1399
1469
|
}
|
|
1400
1470
|
}
|
|
1401
1471
|
//#endregion
|
|
1472
|
+
//#region src/skillfs/audit.ts
|
|
1473
|
+
/** 单文件字节上限:技能是纯文本加少量脚本,2 MiB 已远超正常体积。 */
|
|
1474
|
+
const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024;
|
|
1475
|
+
/** 单个技能总字节上限。 */
|
|
1476
|
+
const MAX_SKILL_BUNDLE_BYTES = 10 * 1024 * 1024;
|
|
1477
|
+
/** agentskills.io 规范对 description 的硬上限。 */
|
|
1478
|
+
const MAX_DESCRIPTION_CHARS = 1024;
|
|
1479
|
+
/** 裸 ESC —— 终端转义注入(CWE-150)的起点,正常技能正文不含。 */
|
|
1480
|
+
const ANSI_RE = /\u001B/;
|
|
1481
|
+
/** Unicode 标签字符区,唯一用途是承载不可见文本。 */
|
|
1482
|
+
const UNICODE_TAG_RE = /[\u{E0000}-\u{E007F}]/u;
|
|
1483
|
+
/** 双向控制符:Trojan Source 式视觉重排。 */
|
|
1484
|
+
const BIDI_RE = /[\u202A-\u202E\u2066-\u2069]/;
|
|
1485
|
+
/** 零宽字符:可能来自从网页复制的正文,也可能用于隐藏内容。 */
|
|
1486
|
+
const ZERO_WIDTH_RE = /[\u200B-\u200D\u2060\uFEFF]/;
|
|
1487
|
+
/**
|
|
1488
|
+
* 危险命令:只匹配「下载后直接执行」这一族,也就是审计报告里出现频率最高、
|
|
1489
|
+
* 且人类肉眼最容易滑过去的那几种形态。普通的 `curl URL` 取数据不在此列。
|
|
1490
|
+
*/
|
|
1491
|
+
const DANGEROUS_COMMANDS = [
|
|
1492
|
+
{
|
|
1493
|
+
re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|k)?sh\b/,
|
|
1494
|
+
label: "remote script piped straight into a shell"
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?python[0-9.]*\b/,
|
|
1498
|
+
label: "remote script piped straight into python"
|
|
1499
|
+
},
|
|
1500
|
+
{
|
|
1501
|
+
re: /\bbase64\s+(?:-d|--decode)\b[^\n|]*\|\s*(?:ba|z|k)?sh\b/,
|
|
1502
|
+
label: "base64 payload decoded into a shell"
|
|
1503
|
+
},
|
|
1504
|
+
{
|
|
1505
|
+
re: /\beval\s+["']?\$\(\s*(?:curl|wget)\b/,
|
|
1506
|
+
label: "eval of a downloaded command"
|
|
1507
|
+
},
|
|
1508
|
+
{
|
|
1509
|
+
re: /\bnc\b[^\n]*\s-e\s/,
|
|
1510
|
+
label: "netcat with command execution"
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
re: /\/dev\/tcp\//,
|
|
1514
|
+
label: "raw TCP socket via /dev/tcp"
|
|
1515
|
+
}
|
|
1516
|
+
];
|
|
1517
|
+
/** 凭据路径:技能正文提到这些路径本身未必有恶意,但值得让人看一眼上下文。 */
|
|
1518
|
+
const CREDENTIAL_PATHS = [
|
|
1519
|
+
{
|
|
1520
|
+
re: /(?:^|[\s"'`/])\.ssh\/id_(?:rsa|dsa|ecdsa|ed25519)\b/,
|
|
1521
|
+
label: "SSH private key"
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
re: /(?:^|[\s"'`/])\.aws\/credentials\b/,
|
|
1525
|
+
label: "AWS credentials file"
|
|
1526
|
+
},
|
|
1527
|
+
{
|
|
1528
|
+
re: /(?:^|[\s"'`/])\.netrc\b/,
|
|
1529
|
+
label: "netrc credentials file"
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
re: /Library\/Keychains\b/,
|
|
1533
|
+
label: "macOS keychain"
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
re: /(?:^|[\s"'`])\.docker\/config\.json\b/,
|
|
1537
|
+
label: "Docker registry credentials"
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
re: /(?:^|\s)\/etc\/shadow\b/,
|
|
1541
|
+
label: "system password database"
|
|
1542
|
+
}
|
|
1543
|
+
];
|
|
1544
|
+
/** 1 起行号(按 `\n` 计)。 */
|
|
1545
|
+
function lineOf(text, index) {
|
|
1546
|
+
let line = 1;
|
|
1547
|
+
const end = Math.min(index, text.length);
|
|
1548
|
+
for (let i = 0; i < end; i += 1) if (text.charCodeAt(i) === 10) line += 1;
|
|
1549
|
+
return line;
|
|
1550
|
+
}
|
|
1551
|
+
/** 首个匹配位置与总数。每次新建带 `g` 的正则,避免 lastIndex 跨调用残留。 */
|
|
1552
|
+
function collect$1(text, re) {
|
|
1553
|
+
const global = new RegExp(re.source, "gu");
|
|
1554
|
+
let first = -1;
|
|
1555
|
+
let count = 0;
|
|
1556
|
+
let match;
|
|
1557
|
+
while ((match = global.exec(text)) !== null) {
|
|
1558
|
+
if (first === -1) first = match.index;
|
|
1559
|
+
count += 1;
|
|
1560
|
+
if (match[0].length === 0) global.lastIndex += 1;
|
|
1561
|
+
}
|
|
1562
|
+
return {
|
|
1563
|
+
first,
|
|
1564
|
+
count
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* 审计一段技能文本(SKILL.md 正文、脚本、参考文档都适用)。
|
|
1569
|
+
* 纯函数:同样的输入永远给同样的结果,便于单测与「改动前后对比」。
|
|
1570
|
+
*/
|
|
1571
|
+
function auditSkillText(relativePath, text) {
|
|
1572
|
+
const findings = [];
|
|
1573
|
+
const body = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
1574
|
+
const push = (rule, severity, message, index) => {
|
|
1575
|
+
findings.push({
|
|
1576
|
+
rule,
|
|
1577
|
+
severity,
|
|
1578
|
+
file: relativePath,
|
|
1579
|
+
line: lineOf(body, index),
|
|
1580
|
+
message
|
|
1581
|
+
});
|
|
1582
|
+
};
|
|
1583
|
+
if (ANSI_RE.test(body)) push("ansi-escape", "block", "contains a raw ESC control character — it can rewrite what a terminal displays (escape-sequence injection, CWE-150)", body.search(ANSI_RE));
|
|
1584
|
+
const tags = collect$1(body, UNICODE_TAG_RE);
|
|
1585
|
+
if (tags.count > 0) push("unicode-tag", "block", `contains ${tags.count} invisible Unicode tag character(s); text hidden there is not rendered by any editor, so a reviewer cannot see what the agent reads`, tags.first);
|
|
1586
|
+
const bidi = collect$1(body, BIDI_RE);
|
|
1587
|
+
if (bidi.count > 0) push("bidi-control", "block", `contains ${bidi.count} bidirectional control character(s); they can make the file read differently than it executes`, bidi.first);
|
|
1588
|
+
const zeroWidth = collect$1(body, ZERO_WIDTH_RE);
|
|
1589
|
+
if (zeroWidth.count > 0) push("zero-width", "warn", `contains ${zeroWidth.count} zero-width character(s); usually a copy-paste artifact, but they can also split a keyword so a reviewer's search misses it`, zeroWidth.first);
|
|
1590
|
+
for (const { re, label } of DANGEROUS_COMMANDS) {
|
|
1591
|
+
const hit = re.exec(body);
|
|
1592
|
+
if (hit !== null && hit.index !== void 0) {
|
|
1593
|
+
push("dangerous-command", "warn", `contains ${label}: \`${hit[0].trim().slice(0, 120)}\``, hit.index);
|
|
1594
|
+
break;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
for (const { re, label } of CREDENTIAL_PATHS) {
|
|
1598
|
+
const hit = re.exec(body);
|
|
1599
|
+
if (hit !== null && hit.index !== void 0) {
|
|
1600
|
+
push("credential-path", "warn", `references ${label}; check the surrounding instructions actually need it`, hit.index);
|
|
1601
|
+
break;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return findings;
|
|
1605
|
+
}
|
|
1606
|
+
/** description 超出规范上限:部分客户端会截断,技能因此静默失去自动激活能力。 */
|
|
1607
|
+
function auditDescription(file, description) {
|
|
1608
|
+
if (description.length <= 1024) return [];
|
|
1609
|
+
return [{
|
|
1610
|
+
rule: "description-too-long",
|
|
1611
|
+
severity: "warn",
|
|
1612
|
+
file,
|
|
1613
|
+
message: `description is ${description.length} chars; the Agent Skills spec caps it at ${MAX_DESCRIPTION_CHARS} and longer ones get truncated by clients, which silently stops the skill from auto-activating`
|
|
1614
|
+
}];
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* 审计一个完整技能目录:体积/数量上限 + 每个文本文件的规则扫描。
|
|
1618
|
+
* `block` 级发现(体积越界)意味着不该落盘;文本级 `block` 由调用方决定。
|
|
1619
|
+
*/
|
|
1620
|
+
function auditSkillBundle(files, description) {
|
|
1621
|
+
const findings = [];
|
|
1622
|
+
if (files.length > 500) findings.push({
|
|
1623
|
+
rule: "too-many-files",
|
|
1624
|
+
severity: "block",
|
|
1625
|
+
file: ".",
|
|
1626
|
+
message: `skill has ${files.length} files; the hub imports at most 500 per skill`
|
|
1627
|
+
});
|
|
1628
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
1629
|
+
if (totalBytes > 10485760) findings.push({
|
|
1630
|
+
rule: "bundle-too-large",
|
|
1631
|
+
severity: "block",
|
|
1632
|
+
file: ".",
|
|
1633
|
+
message: `skill is ${formatBytes(totalBytes)} in total; the hub imports at most ${formatBytes(MAX_SKILL_BUNDLE_BYTES)} per skill`
|
|
1634
|
+
});
|
|
1635
|
+
for (const file of files) {
|
|
1636
|
+
if (file.size > 2097152) {
|
|
1637
|
+
findings.push({
|
|
1638
|
+
rule: "file-too-large",
|
|
1639
|
+
severity: "block",
|
|
1640
|
+
file: file.path,
|
|
1641
|
+
message: `${formatBytes(file.size)} is over the ${formatBytes(MAX_SKILL_FILE_BYTES)} per-file limit`
|
|
1642
|
+
});
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
if (file.text !== void 0) findings.push(...auditSkillText(file.path, file.text));
|
|
1646
|
+
}
|
|
1647
|
+
if (description !== void 0) findings.push(...auditDescription("SKILL.md", description));
|
|
1648
|
+
return findings;
|
|
1649
|
+
}
|
|
1650
|
+
/** 阻断性发现的单行摘要(导入失败时的错误文本)。 */
|
|
1651
|
+
function blockingReason(findings) {
|
|
1652
|
+
const blocked = findings.filter((finding) => finding.severity === "block");
|
|
1653
|
+
if (blocked.length === 0) return void 0;
|
|
1654
|
+
const head = blocked.slice(0, 3).map((finding) => `${finding.file}${finding.line !== void 0 ? ":" + String(finding.line) : ""} ${finding.message}`);
|
|
1655
|
+
const rest = blocked.length > head.length ? ` (+${blocked.length - head.length} more)` : "";
|
|
1656
|
+
return "skill rejected by content audit: " + head.join("; ") + rest;
|
|
1657
|
+
}
|
|
1658
|
+
/** 字节数的人读形式(诊断文案用)。 */
|
|
1659
|
+
function formatBytes(bytes) {
|
|
1660
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1661
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
1662
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
1663
|
+
}
|
|
1664
|
+
//#endregion
|
|
1402
1665
|
//#region src/concurrency.ts
|
|
1403
1666
|
/**
|
|
1404
1667
|
* 并发工具:有界并发的 map。原先 repo.ts 与 stats.ts 各有一份实现,
|
|
@@ -1594,6 +1857,14 @@ async function scanDiagnostics(root, home = dshHome()) {
|
|
|
1594
1857
|
});
|
|
1595
1858
|
return found;
|
|
1596
1859
|
}
|
|
1860
|
+
const auditFindings = [...auditSkillText(basename(entry.path), text), ...auditDescription(basename(entry.path), parsed.value.description)];
|
|
1861
|
+
for (const finding of auditFindings) found.push({
|
|
1862
|
+
path: entry.path,
|
|
1863
|
+
root,
|
|
1864
|
+
reason: (finding.line !== void 0 ? `line ${finding.line}: ` : "") + finding.message,
|
|
1865
|
+
kind: "security",
|
|
1866
|
+
severity: finding.severity
|
|
1867
|
+
});
|
|
1597
1868
|
if (repairFrontmatterFileText(text) !== null) found.push({
|
|
1598
1869
|
path: entry.path,
|
|
1599
1870
|
root,
|
|
@@ -1678,6 +1949,302 @@ async function occupancyOf(base, name) {
|
|
|
1678
1949
|
return result;
|
|
1679
1950
|
}
|
|
1680
1951
|
//#endregion
|
|
1952
|
+
//#region src/skillfs/hash.ts
|
|
1953
|
+
/**
|
|
1954
|
+
* 技能内容哈希:给「本地这份技能到底有没有被改过」一个确定答案。
|
|
1955
|
+
*
|
|
1956
|
+
* 现有来源记录只存上游的 commit 与按路径的字节数基线,能回答「上游变没变」,
|
|
1957
|
+
* 回答不了「磁盘上这份还是不是我当初导入的那份」。于是「一键全更」只能盲覆盖,
|
|
1958
|
+
* 用户唯一的保护是弹窗里那句他自己也判断不了的「会覆盖本地修改」。
|
|
1959
|
+
*
|
|
1960
|
+
* 哈希的粒度是**整个技能**(目录包 = 目录下全部文件;扁平技能 = 那一个文件),
|
|
1961
|
+
* 而不是单文件:用户改的是「这个技能」,不是「这个技能里的第 3 个文件」。
|
|
1962
|
+
*
|
|
1963
|
+
* 边界(与体积审计同一套取舍):技能是纯文本加少量脚本,正常体积在几十 KB。
|
|
1964
|
+
* 走查上限存在的意义是「一个恶意或事故性的巨大目录不能把 /catalog 拖死」,
|
|
1965
|
+
* 不是为了支持大包。
|
|
1966
|
+
*/
|
|
1967
|
+
/** 单个技能参与哈希的文件数上限;超出部分不参与(并在结果里体现为截断)。 */
|
|
1968
|
+
const MAX_FILES = 2e3;
|
|
1969
|
+
/** 目录走查深度上限。 */
|
|
1970
|
+
const MAX_DEPTH = 8;
|
|
1971
|
+
/** 超过这个大小的文件不读内容,只把大小计入(避免一次同步卡在几个大二进制上)。 */
|
|
1972
|
+
const MAX_HASH_BYTES = 8 * 1024 * 1024;
|
|
1973
|
+
/** 递归收集相对路径与大小。符号链接不跟随(lstat),避免指到目录外。 */
|
|
1974
|
+
async function collect(dir, depth, out) {
|
|
1975
|
+
if (depth > MAX_DEPTH || out.length >= MAX_FILES) return;
|
|
1976
|
+
let names;
|
|
1977
|
+
try {
|
|
1978
|
+
names = await readdir(dir);
|
|
1979
|
+
} catch {
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
names.sort();
|
|
1983
|
+
for (const name of names) {
|
|
1984
|
+
if (out.length >= MAX_FILES) return;
|
|
1985
|
+
if (name === ".git" || name === ".trash") continue;
|
|
1986
|
+
const absolute = join(dir, name);
|
|
1987
|
+
let info;
|
|
1988
|
+
try {
|
|
1989
|
+
info = await lstat(absolute);
|
|
1990
|
+
} catch {
|
|
1991
|
+
continue;
|
|
1992
|
+
}
|
|
1993
|
+
if (info.isSymbolicLink()) continue;
|
|
1994
|
+
if (info.isDirectory()) await collect(absolute, depth + 1, out);
|
|
1995
|
+
else if (info.isFile()) out.push({
|
|
1996
|
+
path: relative(dir, absolute),
|
|
1997
|
+
size: info.size,
|
|
1998
|
+
absolute
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
/** 把一组 (相对路径, 大小, 内容摘要) 折叠成一个十六进制摘要。 */
|
|
2003
|
+
function fold(entries) {
|
|
2004
|
+
const digest = createHash("sha256");
|
|
2005
|
+
for (const entry of [...entries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
|
|
2006
|
+
digest.update(entry.path);
|
|
2007
|
+
digest.update("\0");
|
|
2008
|
+
digest.update(String(entry.size));
|
|
2009
|
+
digest.update("\0");
|
|
2010
|
+
digest.update(entry.digest);
|
|
2011
|
+
digest.update("\0");
|
|
2012
|
+
}
|
|
2013
|
+
return digest.digest("hex");
|
|
2014
|
+
}
|
|
2015
|
+
/** 单文件内容摘要;不可读或过大时给一个稳定占位符。 */
|
|
2016
|
+
async function digestFile(absolute, size) {
|
|
2017
|
+
if (size > MAX_HASH_BYTES) return "oversize";
|
|
2018
|
+
try {
|
|
2019
|
+
return createHash("sha256").update(await readFile(absolute)).digest("hex");
|
|
2020
|
+
} catch {
|
|
2021
|
+
return "unreadable";
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* 一个技能目录的内容哈希。目录不可读时返回 undefined(调用方据此跳过比对,
|
|
2026
|
+
* 而不是把它当成「改过了」—— 读不到不等于变了)。
|
|
2027
|
+
*/
|
|
2028
|
+
async function hashSkillDirectory(dir) {
|
|
2029
|
+
const collected = [];
|
|
2030
|
+
await collect(dir, 0, collected);
|
|
2031
|
+
if (collected.length === 0) return void 0;
|
|
2032
|
+
const entries = [];
|
|
2033
|
+
for (const file of collected) entries.push({
|
|
2034
|
+
path: file.path,
|
|
2035
|
+
size: file.size,
|
|
2036
|
+
digest: await digestFile(file.absolute, file.size)
|
|
2037
|
+
});
|
|
2038
|
+
return fold(entries);
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* 从一个发现文件推断技能形态并算哈希:`SKILL.md`(含 `.disabled` 变体)是
|
|
2042
|
+
* 目录包,其余 `<name>.md` 是扁平技能。两种形态共用同一个返回契约。
|
|
2043
|
+
*/
|
|
2044
|
+
async function hashSkillAt(discoveryPath) {
|
|
2045
|
+
const file = basename(discoveryPath).replace(/\.disabled$/, "");
|
|
2046
|
+
if (file === "SKILL.md") return hashSkillDirectory(dirname(discoveryPath));
|
|
2047
|
+
try {
|
|
2048
|
+
const info = await lstat(discoveryPath);
|
|
2049
|
+
if (!info.isFile()) return void 0;
|
|
2050
|
+
return fold([{
|
|
2051
|
+
path: file,
|
|
2052
|
+
size: info.size,
|
|
2053
|
+
digest: await digestFile(discoveryPath, info.size)
|
|
2054
|
+
}]);
|
|
2055
|
+
} catch {
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
//#endregion
|
|
2060
|
+
//#region src/skillfs/foreign.ts
|
|
2061
|
+
/**
|
|
2062
|
+
* 外部 agent 技能的只读发现与收编。
|
|
2063
|
+
*
|
|
2064
|
+
* SKILL.md 已经是跨 agent 的事实标准(Claude Code / Codex / Cursor / Gemini CLI
|
|
2065
|
+
* 等 45+ 产品读同一份文件),但**发现路径各家不同**。于是「我在 Claude Code 里
|
|
2066
|
+
* 调好的技能」在 dsh 里看不见,用户只能手工复制目录。
|
|
2067
|
+
*
|
|
2068
|
+
* 这里只做两件事:读(列出别处有什么)和复制(把选中的收编到本地可写根)。
|
|
2069
|
+
* 外部目录一律**只读**,绝不改写 —— 那是别的 agent 的资产。
|
|
2070
|
+
*/
|
|
2071
|
+
/**
|
|
2072
|
+
* 常见 agent 的用户级技能目录。`~/.agents/skills` 不在此列 —— 那是 hub 自己的
|
|
2073
|
+
* 可写根(user-agents),重复列会让同一个技能出现两次。
|
|
2074
|
+
* 路径取自 vercel-labs/skills 的 agent 注册表,只保留桌面端常见的几个。
|
|
2075
|
+
*/
|
|
2076
|
+
function foreignRoots(userHome) {
|
|
2077
|
+
return [
|
|
2078
|
+
{
|
|
2079
|
+
agent: "claude-code",
|
|
2080
|
+
dir: join(userHome, ".claude", "skills")
|
|
2081
|
+
},
|
|
2082
|
+
{
|
|
2083
|
+
agent: "codex",
|
|
2084
|
+
dir: join(userHome, ".codex", "skills")
|
|
2085
|
+
},
|
|
2086
|
+
{
|
|
2087
|
+
agent: "cursor",
|
|
2088
|
+
dir: join(userHome, ".cursor", "skills")
|
|
2089
|
+
},
|
|
2090
|
+
{
|
|
2091
|
+
agent: "gemini-cli",
|
|
2092
|
+
dir: join(userHome, ".gemini", "skills")
|
|
2093
|
+
},
|
|
2094
|
+
{
|
|
2095
|
+
agent: "copilot",
|
|
2096
|
+
dir: join(userHome, ".copilot", "skills")
|
|
2097
|
+
},
|
|
2098
|
+
{
|
|
2099
|
+
agent: "opencode",
|
|
2100
|
+
dir: join(userHome, ".config", "opencode", "skills")
|
|
2101
|
+
},
|
|
2102
|
+
{
|
|
2103
|
+
agent: "goose",
|
|
2104
|
+
dir: join(userHome, ".config", "goose", "skills")
|
|
2105
|
+
}
|
|
2106
|
+
];
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* 扫描所有外部根。解析不出 frontmatter 的条目直接跳过 —— 它们在任何 agent 里
|
|
2110
|
+
* 都不会生效,列出来只会让人以为「导入就好了」。
|
|
2111
|
+
*/
|
|
2112
|
+
async function scanForeignSkills(userHome, existing = /* @__PURE__ */ new Set()) {
|
|
2113
|
+
const found = [];
|
|
2114
|
+
for (const root of foreignRoots(userHome)) for (const entry of await scanRoot(root.dir)) {
|
|
2115
|
+
let text;
|
|
2116
|
+
try {
|
|
2117
|
+
text = await readFile(entry.path, "utf8");
|
|
2118
|
+
} catch {
|
|
2119
|
+
continue;
|
|
2120
|
+
}
|
|
2121
|
+
const parsed = parseFrontmatter(text);
|
|
2122
|
+
if ("error" in parsed) continue;
|
|
2123
|
+
const name = entry.kind === "directory" ? basename(entry.directory) : basename(entry.path, ".md");
|
|
2124
|
+
found.push({
|
|
2125
|
+
name,
|
|
2126
|
+
description: parsed.value.description,
|
|
2127
|
+
agent: root.agent,
|
|
2128
|
+
path: entry.path,
|
|
2129
|
+
directory: entry.directory,
|
|
2130
|
+
kind: entry.kind,
|
|
2131
|
+
existing: existing.has(name)
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
return found.sort((a, b) => a.name === b.name ? a.agent.localeCompare(b.agent) : a.name.localeCompare(b.name));
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* 递归列文件。符号链接不跟随(lstat):一个指向 `~/.ssh` 的链接不该因为
|
|
2138
|
+
* 「用户点了导入」就被复制进技能目录。
|
|
2139
|
+
*/
|
|
2140
|
+
async function walk(dir, base, depth, out) {
|
|
2141
|
+
if (depth > 8 || out.length >= 500) return;
|
|
2142
|
+
let names;
|
|
2143
|
+
try {
|
|
2144
|
+
names = await readdir(dir);
|
|
2145
|
+
} catch {
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
names.sort();
|
|
2149
|
+
for (const name of names) {
|
|
2150
|
+
if (out.length >= 500) return;
|
|
2151
|
+
if (name === ".git") continue;
|
|
2152
|
+
const absolute = join(dir, name);
|
|
2153
|
+
let info;
|
|
2154
|
+
try {
|
|
2155
|
+
info = await lstat(absolute);
|
|
2156
|
+
} catch {
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
if (info.isSymbolicLink()) continue;
|
|
2160
|
+
if (info.isDirectory()) await walk(absolute, base, depth + 1, out);
|
|
2161
|
+
else if (info.isFile()) out.push({
|
|
2162
|
+
path: relative(base, absolute),
|
|
2163
|
+
absolute,
|
|
2164
|
+
size: info.size
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
async function collectForCopy(skill) {
|
|
2169
|
+
if (skill.kind === "flat") {
|
|
2170
|
+
const info = await lstat(skill.path);
|
|
2171
|
+
return [{
|
|
2172
|
+
path: basename(skill.path),
|
|
2173
|
+
absolute: skill.path,
|
|
2174
|
+
size: info.size
|
|
2175
|
+
}];
|
|
2176
|
+
}
|
|
2177
|
+
const out = [];
|
|
2178
|
+
await walk(skill.directory, skill.directory, 0, out);
|
|
2179
|
+
return out;
|
|
2180
|
+
}
|
|
2181
|
+
/** 文本文件的判定与市场导入一致:无 NUL、解码无替换字符。 */
|
|
2182
|
+
function asText(buffer) {
|
|
2183
|
+
if (buffer.includes(0)) return void 0;
|
|
2184
|
+
const text = buffer.toString("utf8");
|
|
2185
|
+
return text.includes("�") ? void 0 : text;
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* 把一个外部技能收编到 `targetRoot`(通常是 `~/.dsh/skills`)。
|
|
2189
|
+
*
|
|
2190
|
+
* 与市场导入同一套闸:体积/数量上限 + 内容审计,命中阻断级直接拒绝;
|
|
2191
|
+
* 落盘仍是「临时目录 → 校验 → 原子 rename」,失败不留半成品。
|
|
2192
|
+
* 扁平技能统一落成目录包 —— hub 的所有写入路径都建目录包,两种形态并存会
|
|
2193
|
+
* 触发同名不可达(issue #16 那一类)。
|
|
2194
|
+
*/
|
|
2195
|
+
async function importForeignSkill(skill, targetRoot) {
|
|
2196
|
+
const collected = await collectForCopy(skill);
|
|
2197
|
+
if (collected.length === 0) throw new Error("skill has no files to import");
|
|
2198
|
+
const audited = [];
|
|
2199
|
+
const payload = [];
|
|
2200
|
+
for (const file of collected) {
|
|
2201
|
+
const buffer = await readFile(file.absolute);
|
|
2202
|
+
const text = asText(buffer);
|
|
2203
|
+
audited.push({
|
|
2204
|
+
path: file.path,
|
|
2205
|
+
size: buffer.length,
|
|
2206
|
+
...text !== void 0 ? { text } : {}
|
|
2207
|
+
});
|
|
2208
|
+
payload.push({
|
|
2209
|
+
relative: file.path,
|
|
2210
|
+
buffer
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
const blocked = blockingReason(auditSkillBundle(audited));
|
|
2214
|
+
if (blocked !== void 0) throw new Error(blocked);
|
|
2215
|
+
await mkdir(targetRoot, { recursive: true });
|
|
2216
|
+
const tempDir = await mkdtemp(join(targetRoot, "." + skill.name + ".foreign-"));
|
|
2217
|
+
try {
|
|
2218
|
+
for (const file of payload) {
|
|
2219
|
+
const target = join(tempDir, file.relative);
|
|
2220
|
+
await mkdir(dirname(target), { recursive: true });
|
|
2221
|
+
await writeFile(target, file.buffer);
|
|
2222
|
+
}
|
|
2223
|
+
const discovery = skill.kind === "flat" ? join(tempDir, basename(skill.path)) : join(tempDir, "SKILL.md");
|
|
2224
|
+
let text;
|
|
2225
|
+
try {
|
|
2226
|
+
text = await readFile(discovery, "utf8");
|
|
2227
|
+
} catch {
|
|
2228
|
+
throw new Error("imported skill has no SKILL.md");
|
|
2229
|
+
}
|
|
2230
|
+
const parsed = parseFrontmatter(text);
|
|
2231
|
+
if ("error" in parsed) throw new Error("imported skill rejected: " + parsed.error);
|
|
2232
|
+
if (parsed.value.name !== skill.name) throw new Error(`skill declares name "${parsed.value.name}", expected "${skill.name}"`);
|
|
2233
|
+
if (skill.kind === "flat") {
|
|
2234
|
+
await mkdir(join(tempDir, skill.name), { recursive: true });
|
|
2235
|
+
await rename(discovery, join(tempDir, skill.name, "SKILL.md"));
|
|
2236
|
+
}
|
|
2237
|
+
const targetDir = join(targetRoot, skill.name);
|
|
2238
|
+
await rename(skill.kind === "flat" ? join(tempDir, skill.name) : tempDir, targetDir);
|
|
2239
|
+
return join(targetDir, "SKILL.md");
|
|
2240
|
+
} finally {
|
|
2241
|
+
await rm(tempDir, {
|
|
2242
|
+
recursive: true,
|
|
2243
|
+
force: true
|
|
2244
|
+
}).catch(() => {});
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
//#endregion
|
|
1681
2248
|
//#region src/skillfs.ts
|
|
1682
2249
|
/**
|
|
1683
2250
|
* Skill filesystem operations for the writable roots and the hub provider's
|
|
@@ -2299,23 +2866,50 @@ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch, signal) {
|
|
|
2299
2866
|
}
|
|
2300
2867
|
}
|
|
2301
2868
|
/**
|
|
2869
|
+
* Treat a downloaded buffer as text when it plausibly is one: no NUL byte and
|
|
2870
|
+
* no U+FFFD from a failed UTF-8 decode. Binary files (icons, archives) are
|
|
2871
|
+
* size-checked only — running the text rules over a PNG would produce noise.
|
|
2872
|
+
*/
|
|
2873
|
+
function asAuditText(buffer) {
|
|
2874
|
+
if (buffer.includes(0)) return void 0;
|
|
2875
|
+
const text = buffer.toString("utf8");
|
|
2876
|
+
return text.includes("�") ? void 0 : text;
|
|
2877
|
+
}
|
|
2878
|
+
/**
|
|
2302
2879
|
* Download a full skill directory into a temporary dir, validate SKILL.md,
|
|
2303
2880
|
* then atomically rename it into place. The target root is created when missing.
|
|
2304
2881
|
* Callers pass a target root and a skill entry plus its collected files.
|
|
2882
|
+
*
|
|
2883
|
+
* 写盘前有两道闸:体积/数量上限(预检用 tree 的 size,落盘前再用实际字节复核)
|
|
2884
|
+
* 和内容审计(隐藏字符、危险命令、凭据路径)。审计的 `block` 级发现直接拒绝
|
|
2885
|
+
* 导入 —— 一个能藏住指令的技能不该靠用户「看得仔细」来兜底。
|
|
2305
2886
|
*/
|
|
2306
2887
|
async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl = fetch, signal, onProgress) {
|
|
2307
2888
|
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
|
|
2889
|
+
const preflight = blockingReason(auditSkillBundle(files.map((file) => ({
|
|
2890
|
+
path: relativeToSkillDir(entry.dir, file.path),
|
|
2891
|
+
size: file.size
|
|
2892
|
+
}))));
|
|
2893
|
+
if (preflight !== void 0) throw new RepoFetchError(preflight, 422);
|
|
2308
2894
|
const targetDir = join(targetRoot, entry.name);
|
|
2309
2895
|
await mkdir(targetRoot, { recursive: true });
|
|
2310
2896
|
const tempDir = await mkdtemp(join(targetRoot, "." + entry.name + ".import-"));
|
|
2311
2897
|
let renamed = false;
|
|
2312
2898
|
try {
|
|
2899
|
+
const audited = [];
|
|
2313
2900
|
await mapConcurrent(files, 6, async (file) => {
|
|
2314
2901
|
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
|
|
2315
2902
|
const relative = relativeToSkillDir(entry.dir, file.path);
|
|
2316
2903
|
if (relative === "" || relative.includes("..")) throw new RepoFetchError("unsafe repo path: " + file.path);
|
|
2317
2904
|
const target = join(tempDir, relative);
|
|
2318
2905
|
const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl, signal);
|
|
2906
|
+
if (buffer.length > 2097152) throw new RepoFetchError(`skill rejected by content audit: ${relative} is ${buffer.length} bytes, over the per-file limit`, 422);
|
|
2907
|
+
const text = asAuditText(buffer);
|
|
2908
|
+
audited.push({
|
|
2909
|
+
path: relative,
|
|
2910
|
+
size: buffer.length,
|
|
2911
|
+
...text !== void 0 ? { text } : {}
|
|
2912
|
+
});
|
|
2319
2913
|
await mkdir(dirname(target), { recursive: true });
|
|
2320
2914
|
await writeFile(target, buffer);
|
|
2321
2915
|
onProgress?.(buffer.length, relative);
|
|
@@ -2330,6 +2924,8 @@ async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl
|
|
|
2330
2924
|
const parsed = parseFrontmatter(text);
|
|
2331
2925
|
if ("error" in parsed) throw new RepoFetchError("downloaded skill rejected: " + parsed.error, 422);
|
|
2332
2926
|
if (parsed.value.name !== entry.name) throw new RepoFetchError("downloaded skill declares name \"" + parsed.value.name + "\", expected \"" + entry.name + "\"", 422);
|
|
2927
|
+
const blocked = blockingReason(auditSkillBundle(audited, parsed.value.description));
|
|
2928
|
+
if (blocked !== void 0) throw new RepoFetchError(blocked, 422);
|
|
2333
2929
|
await rename(tempDir, targetDir);
|
|
2334
2930
|
renamed = true;
|
|
2335
2931
|
return {
|
|
@@ -2525,6 +3121,13 @@ function homeOf(deps) {
|
|
|
2525
3121
|
return deps.home ?? dshHome();
|
|
2526
3122
|
}
|
|
2527
3123
|
/**
|
|
3124
|
+
* Resolve the user's home directory — the base every other agent's user-level
|
|
3125
|
+
* skill folder hangs off. Distinct from `homeOf` (the DSH state root).
|
|
3126
|
+
*/
|
|
3127
|
+
function userHomeOf(deps) {
|
|
3128
|
+
return deps.userHome ?? homedir();
|
|
3129
|
+
}
|
|
3130
|
+
/**
|
|
2528
3131
|
* Resolve a skill by name as something the hub may write, or the exact
|
|
2529
3132
|
* refusal the caller should answer with. Owns the guard sequence shared by
|
|
2530
3133
|
* toggle, toggle-batch, and skill/delete: registry lookup, writable source,
|
|
@@ -2856,6 +3459,7 @@ async function buildCatalog(deps, cwd) {
|
|
|
2856
3459
|
skills,
|
|
2857
3460
|
disabled,
|
|
2858
3461
|
diagnostics,
|
|
3462
|
+
announcement: computeAnnouncementBudget(skills),
|
|
2859
3463
|
...duplicateNames.length > 0 ? { duplicateNames } : {}
|
|
2860
3464
|
};
|
|
2861
3465
|
}
|
|
@@ -3930,6 +4534,8 @@ function repoImportRoutes(deps) {
|
|
|
3930
4534
|
});
|
|
3931
4535
|
await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name);
|
|
3932
4536
|
await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
|
|
4537
|
+
const hash = await hashSkillAt(result.skillPath);
|
|
4538
|
+
if (hash !== void 0) await deps.store.setSourceSkillHash(repo, entry.name, hash);
|
|
3933
4539
|
if (defaultTag !== void 0) await deps.store.addSkillToTag(defaultTag.id, entry.name);
|
|
3934
4540
|
job.imported.push({
|
|
3935
4541
|
name: entry.name,
|
|
@@ -4166,6 +4772,29 @@ function groupRoutes(deps) {
|
|
|
4166
4772
|
* 删除 / restore 恢复 / trash/clear 清空回收站。从 routes.ts 原样搬出,
|
|
4167
4773
|
* handler 逻辑不变。
|
|
4168
4774
|
*/
|
|
4775
|
+
/**
|
|
4776
|
+
* 本地被改过的技能:磁盘内容哈希 ≠ 导入/同步时记录的哈希。
|
|
4777
|
+
*
|
|
4778
|
+
* 只对「记录里已有哈希」的技能判定 —— 旧记录没采集过哈希,硬比会得到
|
|
4779
|
+
* 「全部已改」的假警报。技能目录读不到(被删/被移走)同样不算改过:
|
|
4780
|
+
* 那是另一种状态,由发现诊断去报。
|
|
4781
|
+
*/
|
|
4782
|
+
async function localModifiedSkills(source, home) {
|
|
4783
|
+
const hashes = source.localHashes;
|
|
4784
|
+
if (hashes === void 0 || Object.keys(hashes).length === 0) return [];
|
|
4785
|
+
const base = rootPath("user-dsh", home);
|
|
4786
|
+
const modified = [];
|
|
4787
|
+
await mapConcurrent(source.skills, 8, async (name) => {
|
|
4788
|
+
const expected = hashes[name];
|
|
4789
|
+
if (expected === void 0) return;
|
|
4790
|
+
const occupancy = await occupancyOf(base, name);
|
|
4791
|
+
const path = occupancy.enabled[0] ?? occupancy.disabled[0];
|
|
4792
|
+
if (path === void 0) return;
|
|
4793
|
+
const actual = await hashSkillAt(path);
|
|
4794
|
+
if (actual !== void 0 && actual !== expected) modified.push(name);
|
|
4795
|
+
});
|
|
4796
|
+
return modified.sort((a, b) => a.localeCompare(b));
|
|
4797
|
+
}
|
|
4169
4798
|
/** 来源跟踪域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
4170
4799
|
function sourceRoutes(deps) {
|
|
4171
4800
|
return [
|
|
@@ -4206,11 +4835,14 @@ function sourceRoutes(deps) {
|
|
|
4206
4835
|
}
|
|
4207
4836
|
const sources = source !== void 0 ? [source] : await deps.store.listSources();
|
|
4208
4837
|
if (lastSourceCheck.size > 500) lastSourceCheck.clear();
|
|
4838
|
+
const home = homeOf(deps);
|
|
4209
4839
|
const results = [];
|
|
4210
4840
|
for (const item of sources) {
|
|
4841
|
+
const modified = await localModifiedSkills(item, home);
|
|
4211
4842
|
const base = {
|
|
4212
4843
|
repo: item.repo,
|
|
4213
|
-
...item.ref !== void 0 ? { ref: item.ref } : {}
|
|
4844
|
+
...item.ref !== void 0 ? { ref: item.ref } : {},
|
|
4845
|
+
...modified.length > 0 ? { localModified: modified } : {}
|
|
4214
4846
|
};
|
|
4215
4847
|
const now = Date.now();
|
|
4216
4848
|
if (now - (lastSourceCheck.get(item.repo) ?? 0) < 3e5) {
|
|
@@ -4293,6 +4925,7 @@ function sourceRoutes(deps) {
|
|
|
4293
4925
|
const targetRoot = rootPath("user-dsh", homeOf(deps));
|
|
4294
4926
|
await mkdir(targetRoot, { recursive: true });
|
|
4295
4927
|
const synced = [];
|
|
4928
|
+
const overwritten = [];
|
|
4296
4929
|
const failed = [];
|
|
4297
4930
|
for (const name of targets) {
|
|
4298
4931
|
if (!isSkillName(name) || !source.skills.includes(name)) {
|
|
@@ -4318,6 +4951,12 @@ function sourceRoutes(deps) {
|
|
|
4318
4951
|
const record = await deps.store.getDisabled(name);
|
|
4319
4952
|
const occupancy = await occupancyOf(targetRoot, name);
|
|
4320
4953
|
const download = () => downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
|
|
4954
|
+
const expectedHash = source.localHashes?.[name];
|
|
4955
|
+
const localPath = occupancy.enabled[0] ?? occupancy.disabled[0];
|
|
4956
|
+
if (expectedHash !== void 0 && localPath !== void 0) {
|
|
4957
|
+
const actualHash = await hashSkillAt(localPath);
|
|
4958
|
+
if (actualHash !== void 0 && actualHash !== expectedHash) overwritten.push(name);
|
|
4959
|
+
}
|
|
4321
4960
|
await retireNamePlaceholders(deps, targetRoot, name);
|
|
4322
4961
|
const enabled = occupancy.enabled[0];
|
|
4323
4962
|
if (enabled !== void 0) await replaceInPlace(trashSourceOf(enabled), download);
|
|
@@ -4331,6 +4970,12 @@ function sourceRoutes(deps) {
|
|
|
4331
4970
|
});
|
|
4332
4971
|
} else await deps.store.removeDisabled(name);
|
|
4333
4972
|
await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
|
|
4973
|
+
const settled = await occupancyOf(targetRoot, name);
|
|
4974
|
+
const settledPath = settled.enabled[0] ?? settled.disabled[0];
|
|
4975
|
+
if (settledPath !== void 0) {
|
|
4976
|
+
const hash = await hashSkillAt(settledPath);
|
|
4977
|
+
if (hash !== void 0) await deps.store.setSourceSkillHash(repo, name, hash);
|
|
4978
|
+
}
|
|
4334
4979
|
synced.push(name);
|
|
4335
4980
|
} catch (error) {
|
|
4336
4981
|
failed.push({
|
|
@@ -4346,6 +4991,7 @@ function sourceRoutes(deps) {
|
|
|
4346
4991
|
repo,
|
|
4347
4992
|
commitSha: latest.commitSha,
|
|
4348
4993
|
synced,
|
|
4994
|
+
...overwritten.length > 0 ? { overwritten } : {},
|
|
4349
4995
|
failed
|
|
4350
4996
|
});
|
|
4351
4997
|
}
|
|
@@ -4536,6 +5182,134 @@ async function sweepOrphanTrash(deps, home, failed) {
|
|
|
4536
5182
|
return orphans;
|
|
4537
5183
|
}
|
|
4538
5184
|
//#endregion
|
|
5185
|
+
//#region src/routes/foreign.ts
|
|
5186
|
+
/**
|
|
5187
|
+
* 外部 agent 技能域路由:发现(只读扫描别家目录)与收编(复制进本地可写根)。
|
|
5188
|
+
*
|
|
5189
|
+
* 两条约束:
|
|
5190
|
+
* 1. 外部目录只读 —— 扫描不会 mkdir,也不会写任何东西到别家路径下;
|
|
5191
|
+
* 2. 导入不信任前端给的路径:只接受 `agent + name`,服务端重新扫一遍定位,
|
|
5192
|
+
* 否则一个拼出来的绝对路径就能把任意文件复制进技能目录。
|
|
5193
|
+
*/
|
|
5194
|
+
/** 判断一个外部根是否真实存在(只 stat 一次,不列目录)。 */
|
|
5195
|
+
async function dirExists(path) {
|
|
5196
|
+
try {
|
|
5197
|
+
return (await stat(path)).isDirectory();
|
|
5198
|
+
} catch {
|
|
5199
|
+
return false;
|
|
5200
|
+
}
|
|
5201
|
+
}
|
|
5202
|
+
function foreignRoutes(deps) {
|
|
5203
|
+
return [{
|
|
5204
|
+
path: SKILL_HUB_API.foreign,
|
|
5205
|
+
methods: ["GET"],
|
|
5206
|
+
handler: async ({ res }) => {
|
|
5207
|
+
const home = homeOf(deps);
|
|
5208
|
+
const userHome = userHomeOf(deps);
|
|
5209
|
+
const skills = await scanForeignSkills(userHome, await knownSkillNames(deps, { includeDisabled: false }));
|
|
5210
|
+
const roots = await Promise.all(foreignRoots(userHome).map(async (root) => ({
|
|
5211
|
+
...root,
|
|
5212
|
+
present: await dirExists(root.dir)
|
|
5213
|
+
})));
|
|
5214
|
+
const targetRoot = rootPath("user-dsh", home);
|
|
5215
|
+
const view = await Promise.all(skills.map(async (skill) => ({
|
|
5216
|
+
name: skill.name,
|
|
5217
|
+
description: skill.description,
|
|
5218
|
+
agent: skill.agent,
|
|
5219
|
+
path: skill.path,
|
|
5220
|
+
kind: skill.kind,
|
|
5221
|
+
existing: skill.existing || await pathExists(join(targetRoot, skill.name, "SKILL.md")) || await pathExists(join(targetRoot, skill.name + ".md"))
|
|
5222
|
+
})));
|
|
5223
|
+
writeJson(res, 200, {
|
|
5224
|
+
ok: true,
|
|
5225
|
+
roots: roots.filter((root) => root.present),
|
|
5226
|
+
skills: view
|
|
5227
|
+
});
|
|
5228
|
+
}
|
|
5229
|
+
}, {
|
|
5230
|
+
path: SKILL_HUB_API.foreignImport,
|
|
5231
|
+
methods: ["POST"],
|
|
5232
|
+
jsonBody: true,
|
|
5233
|
+
handler: async ({ res, body }) => {
|
|
5234
|
+
const items = Array.isArray(body.items) ? body.items : [];
|
|
5235
|
+
if (items.length === 0) {
|
|
5236
|
+
writeError(res, 400, "items must be a non-empty array");
|
|
5237
|
+
return;
|
|
5238
|
+
}
|
|
5239
|
+
if (items.length > 100) {
|
|
5240
|
+
writeError(res, 400, "import at most 100 skills per request");
|
|
5241
|
+
return;
|
|
5242
|
+
}
|
|
5243
|
+
const home = homeOf(deps);
|
|
5244
|
+
const available = await scanForeignSkills(userHomeOf(deps), await knownSkillNames(deps, { includeDisabled: false }));
|
|
5245
|
+
const targetRoot = rootPath("user-dsh", home);
|
|
5246
|
+
await mkdir(targetRoot, { recursive: true });
|
|
5247
|
+
const imported = [];
|
|
5248
|
+
const replaced = [];
|
|
5249
|
+
const skipped = [];
|
|
5250
|
+
const failed = [];
|
|
5251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5252
|
+
for (const raw of items) {
|
|
5253
|
+
const item = raw;
|
|
5254
|
+
const agent = item !== null && typeof item.agent === "string" ? item.agent : "";
|
|
5255
|
+
const name = item !== null && typeof item.name === "string" ? item.name : "";
|
|
5256
|
+
if (agent === "" || name === "") {
|
|
5257
|
+
failed.push({
|
|
5258
|
+
name,
|
|
5259
|
+
error: "agent and name are required"
|
|
5260
|
+
});
|
|
5261
|
+
continue;
|
|
5262
|
+
}
|
|
5263
|
+
if (seen.has(agent + "/" + name)) continue;
|
|
5264
|
+
seen.add(agent + "/" + name);
|
|
5265
|
+
const skill = available.find((candidate) => candidate.agent === agent && candidate.name === name);
|
|
5266
|
+
if (skill === void 0) {
|
|
5267
|
+
failed.push({
|
|
5268
|
+
name,
|
|
5269
|
+
error: "skill not found under " + agent
|
|
5270
|
+
});
|
|
5271
|
+
continue;
|
|
5272
|
+
}
|
|
5273
|
+
if (skill.existing) {
|
|
5274
|
+
skipped.push({
|
|
5275
|
+
name,
|
|
5276
|
+
reason: "a skill with this name already exists"
|
|
5277
|
+
});
|
|
5278
|
+
continue;
|
|
5279
|
+
}
|
|
5280
|
+
const occupancy = await occupancyOf(targetRoot, name);
|
|
5281
|
+
if (occupancy.enabled.length > 0) {
|
|
5282
|
+
skipped.push({
|
|
5283
|
+
name,
|
|
5284
|
+
reason: "a skill with this name already exists"
|
|
5285
|
+
});
|
|
5286
|
+
continue;
|
|
5287
|
+
}
|
|
5288
|
+
if (occupancy.disabled.length > 0 || occupancy.bareDir !== void 0) {
|
|
5289
|
+
if (await retireNamePlaceholders(deps, targetRoot, name) !== void 0) replaced.push(name);
|
|
5290
|
+
}
|
|
5291
|
+
try {
|
|
5292
|
+
await importForeignSkill(skill, targetRoot);
|
|
5293
|
+
imported.push(name);
|
|
5294
|
+
} catch (error) {
|
|
5295
|
+
failed.push({
|
|
5296
|
+
name,
|
|
5297
|
+
error: errorText(error)
|
|
5298
|
+
});
|
|
5299
|
+
}
|
|
5300
|
+
}
|
|
5301
|
+
if (imported.length > 0) deps.invalidate?.();
|
|
5302
|
+
writeJson(res, 200, {
|
|
5303
|
+
ok: true,
|
|
5304
|
+
imported,
|
|
5305
|
+
...replaced.length > 0 ? { replaced } : {},
|
|
5306
|
+
skipped,
|
|
5307
|
+
failed
|
|
5308
|
+
});
|
|
5309
|
+
}
|
|
5310
|
+
}];
|
|
5311
|
+
}
|
|
5312
|
+
//#endregion
|
|
4539
5313
|
//#region src/routes.ts
|
|
4540
5314
|
/**
|
|
4541
5315
|
* Build the family's trust fence from the host's `/api` model.
|
|
@@ -4586,7 +5360,8 @@ function makeRoutes(deps) {
|
|
|
4586
5360
|
...marketRoutes(deps),
|
|
4587
5361
|
...repoImportRoutes(deps),
|
|
4588
5362
|
...groupRoutes(deps),
|
|
4589
|
-
...sourceRoutes(deps)
|
|
5363
|
+
...sourceRoutes(deps),
|
|
5364
|
+
...foreignRoutes(deps)
|
|
4590
5365
|
].map((spec) => createRoute(deps, spec)), createRoute(deps, {
|
|
4591
5366
|
kind: "prefix",
|
|
4592
5367
|
path: SKILL_HUB_API_ROOT,
|
|
@@ -5082,7 +5857,7 @@ const CONFIG_FIELDS = [
|
|
|
5082
5857
|
/** Order of the announcement section within the tool-guidance band. */
|
|
5083
5858
|
const SECTION_ORDER = 152;
|
|
5084
5859
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
5085
|
-
const SKILL_HUB_GUIDANCE = ["本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit
|
|
5860
|
+
const SKILL_HUB_GUIDANCE = ["本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;内容审计(隐藏字符、危险命令、凭据路径与体积上限;市场导入命中阻断级规则即整包拒绝);技能目录公告预算(一层渐进加载注入的字符数与 token 估算);本地内容哈希比对(标出被自己改过、同步会覆盖的技能);从其他 agent 的技能目录只读导入(Claude Code / Codex / Cursor / Gemini CLI 等);新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由受 dsh 自身的 /api 信任栅栏保护(回环、监听器绑定地址、--trusted-host 声明的 authority,外加浏览器会话认证),因此反代部署下与 GUI 同一套放行规则。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。", "The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → \"Skills\" is the management page; the plugin's configuration page (enable / announcement toggles, dot colors, GitHub token, stats window) is registered by the plugin itself on its own page in the Plugins manager (sidebar → 插件 → the plugin; collapsed until you expand the title). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with \"check all\" and \"update all\" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics plus a content audit (hidden characters, dangerous commands, credential paths, size caps — a market import with a blocking finding is refused as a whole); the skill-catalog announcement budget (characters and estimated tokens for the first progressive-disclosure layer); local content-hash comparison (flags skills you edited that a sync would overwrite); read-only import from other agents' skill folders (Claude Code / Codex / Cursor / Gemini CLI and friends); new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; the routes sit behind dsh's own /api trust fence (loopback, the listener's bind address, any --trusted-host authority, plus browser-session authentication), so a reverse-proxied deployment is judged exactly like the GUI around it. When the user mentions \"skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill\", this plugin is what they mean — collaborate accordingly."].join("\n\n");
|
|
5086
5861
|
/**
|
|
5087
5862
|
* Mount the skill hub routes and announcement.
|
|
5088
5863
|
* @param ctx - host plugin context carrying webServer/skills/systemPrompt/settings.
|