@michengai/dsh-skills-manager 0.1.15 → 0.1.17
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/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/lib/client.js +16 -0
- package/lib/core.js +17 -8
- package/lib/index.js +16 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -124,7 +124,9 @@ Escape closes only the frontmost upload or confirmation dialog and leaves Settin
|
|
|
124
124
|
|
|
125
125
|
- Enable, disable, and delete accept only one ordinary skill-name path segment.
|
|
126
126
|
- Replacements copy to a temporary sibling path first and keep the original until that succeeds.
|
|
127
|
-
-
|
|
127
|
+
- All endpoints, including GET `/state`, accept only loopback `Host` values (`localhost`, `127.0.0.1`, `[::1]`).
|
|
128
|
+
- Write endpoints also require JSON and the DSH client request marker, so cross-site browser requests cannot trigger local file operations.
|
|
129
|
+
- Import accepts the local path the user selected. The HTTP API trusts loopback callers only; do not expose the host web server beyond this machine.
|
|
128
130
|
|
|
129
131
|
## Secondary development
|
|
130
132
|
|
package/README.zh-CN.md
CHANGED
|
@@ -124,7 +124,9 @@ dsh --profile web --dump-config
|
|
|
124
124
|
|
|
125
125
|
- 启用、停用和删除只接受单个普通技能名称,目录穿越名称会被拒绝。
|
|
126
126
|
- 覆盖前先复制到同目录临时路径;复制成功前不会改动现有技能。
|
|
127
|
-
-
|
|
127
|
+
- 全部接口(含 GET `/state`)只接受 loopback `Host`(`localhost`、`127.0.0.1`、`[::1]`)。
|
|
128
|
+
- 写入接口还要求 JSON 与 DSH 客户端请求标记,跨站浏览器请求不能触发本地文件操作。
|
|
129
|
+
- 导入接受用户选定的本机路径。HTTP 接口只信任 loopback 来源,不要把宿主 webServer 暴露到非本机地址。
|
|
128
130
|
|
|
129
131
|
## 二次开发
|
|
130
132
|
|
package/lib/client.js
CHANGED
|
@@ -55,6 +55,8 @@ window.__ModuleLoader__.load({
|
|
|
55
55
|
"result.done": "上传完成:{names}",
|
|
56
56
|
"result.skipped": "未导入:同名插件已跳过:{names}",
|
|
57
57
|
"result.none": "未导入任何插件。",
|
|
58
|
+
"result.warnings": "警告:{warnings}",
|
|
59
|
+
"warning.backupUncleaned": "旧版本备份未清理: {path}({error})",
|
|
58
60
|
"upload.title": "上传插件",
|
|
59
61
|
"upload.close": "关闭上传弹窗",
|
|
60
62
|
"upload.drop.title": "拖放 SKILL.md 文件到这里",
|
|
@@ -83,7 +85,9 @@ window.__ModuleLoader__.load({
|
|
|
83
85
|
"error.import.invalidName": "无法生成合法 kebab-case 名称(原始名: {name})",
|
|
84
86
|
"error.import.duplicateName": "批量来源中存在多个同名插件: {name}",
|
|
85
87
|
"error.import.failed": "导入失败",
|
|
88
|
+
"error.import.rollbackFailed": "覆盖导入回滚失败,备份保留在: {path}({error})",
|
|
86
89
|
"error.proto.forbidden": "禁止的修改请求(缺少客户端标记)",
|
|
90
|
+
"error.proto.forbiddenHost": "禁止的请求来源(非法 Host)",
|
|
87
91
|
"error.proto.contentType": "请求体必须是 application/json",
|
|
88
92
|
"error.proto.method": "不支持的请求方法",
|
|
89
93
|
"error.proto.unknownAction": "未知操作",
|
|
@@ -138,6 +142,8 @@ window.__ModuleLoader__.load({
|
|
|
138
142
|
"result.done": "Upload complete: {names}",
|
|
139
143
|
"result.skipped": "Not imported: same-name plugins were skipped: {names}",
|
|
140
144
|
"result.none": "Nothing was imported.",
|
|
145
|
+
"result.warnings": "Warning: {warnings}",
|
|
146
|
+
"warning.backupUncleaned": "Old version backup was not cleaned up: {path} ({error})",
|
|
141
147
|
"upload.title": "Upload plugin",
|
|
142
148
|
"upload.close": "Close upload dialog",
|
|
143
149
|
"upload.drop.title": "Drop SKILL.md file here",
|
|
@@ -166,7 +172,9 @@ window.__ModuleLoader__.load({
|
|
|
166
172
|
"error.import.invalidName": "Cannot generate a valid kebab-case name (original name: {name})",
|
|
167
173
|
"error.import.duplicateName": "Batch source contains multiple plugins with the same name: {name}",
|
|
168
174
|
"error.import.failed": "Import failed",
|
|
175
|
+
"error.import.rollbackFailed": "Overwrite import rollback failed; backups kept at: {path} ({error})",
|
|
169
176
|
"error.proto.forbidden": "Forbidden mutation request (missing client marker)",
|
|
177
|
+
"error.proto.forbiddenHost": "Forbidden request origin (invalid host)",
|
|
170
178
|
"error.proto.contentType": "Content type must be application/json",
|
|
171
179
|
"error.proto.method": "Method not allowed",
|
|
172
180
|
"error.proto.unknownAction": "Unknown action",
|
|
@@ -457,6 +465,13 @@ window.__ModuleLoader__.load({
|
|
|
457
465
|
var failedItems = importResult.failed || (importResult.error && importResult.error.failed) || [];
|
|
458
466
|
var skippedNames = (importResult.skipped || []).map(function (item) { return item.name; });
|
|
459
467
|
var failedText = failedItems.map(function (item) { return translateError(t, item); }).join(errorSeparator);
|
|
468
|
+
var warningItems = [];
|
|
469
|
+
(importResult.imported || []).forEach(function (item) {
|
|
470
|
+
if (item.warnings && item.warnings.length) {
|
|
471
|
+
for (var i = 0; i < item.warnings.length; i++) warningItems.push(item.warnings[i]);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
var warningText = warningItems.map(function (item) { return translateError(t, item); }).join(errorSeparator);
|
|
460
475
|
var resultText;
|
|
461
476
|
if (isImportError) {
|
|
462
477
|
// 顶层聚合错误优先展示已翻译的失败明细;无明细时由 translateError 兜底(含 Error 对象)。
|
|
@@ -470,6 +485,7 @@ window.__ModuleLoader__.load({
|
|
|
470
485
|
} else {
|
|
471
486
|
resultText = t("result.none");
|
|
472
487
|
}
|
|
488
|
+
if (warningText) resultText = resultText + errorSeparator + t("result.warnings", { warnings: warningText });
|
|
473
489
|
nodes.push(h("div", { key: "import-result", className: isImportError || failedItems.length ? "dssm-error" : "dssm-note", role: "status" }, resultText));
|
|
474
490
|
}
|
|
475
491
|
if (uploadOpen) {
|
package/lib/core.js
CHANGED
|
@@ -116,7 +116,9 @@ function stripBom(text) {
|
|
|
116
116
|
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/** 解析 SKILL.md 的 frontmatter。返回 { fields, map, body },map 保留键序。
|
|
119
|
+
/** 解析 SKILL.md 的 frontmatter。返回 { fields, map, body },map 保留键序。
|
|
120
|
+
* 只识别顶层 key: value;缩进嵌套字段(如 metadata.source)不进入 map。
|
|
121
|
+
* 写入走 updateInvocationPolicy 的文本级替换,因此嵌套块会被原样保留。 */
|
|
120
122
|
export function parseSkillDoc(text) {
|
|
121
123
|
const src = stripBom(String(text));
|
|
122
124
|
const lines = src.split(/\r?\n/);
|
|
@@ -180,7 +182,8 @@ export function unquote(v) {
|
|
|
180
182
|
return s;
|
|
181
183
|
}
|
|
182
184
|
|
|
183
|
-
/** 仅更新调用策略行,避免重写并破坏其他 YAML frontmatter。
|
|
185
|
+
/** 仅更新调用策略行,避免重写并破坏其他 YAML frontmatter。
|
|
186
|
+
* 启用时删除这两行以恢复缺省可调用,不还原这两行的原始文本或行内注释。 */
|
|
184
187
|
function updateInvocationPolicy(text, enabled) {
|
|
185
188
|
const source = String(text);
|
|
186
189
|
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
|
@@ -423,14 +426,16 @@ async function replaceWithCopy(source, dest, isDir, existing = []) {
|
|
|
423
426
|
try {
|
|
424
427
|
await fs.rename(item.backup, item.path);
|
|
425
428
|
} catch (rollbackError) {
|
|
426
|
-
rollbackFailures.push(
|
|
429
|
+
rollbackFailures.push(item.backup);
|
|
427
430
|
}
|
|
428
431
|
}
|
|
429
432
|
if (rollbackFailures.length) {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
433
|
+
const causeText = String(error && error.message ? error.message : error);
|
|
434
|
+
throw codedError(
|
|
435
|
+
`${causeText};覆盖导入回滚失败,备份保留在: ${rollbackFailures.join("、")}`,
|
|
436
|
+
"error.import.rollbackFailed",
|
|
437
|
+
{ path: rollbackFailures.join("; "), error: causeText },
|
|
438
|
+
);
|
|
434
439
|
}
|
|
435
440
|
throw error;
|
|
436
441
|
}
|
|
@@ -439,7 +444,11 @@ async function replaceWithCopy(source, dest, isDir, existing = []) {
|
|
|
439
444
|
try {
|
|
440
445
|
await fs.rm(item.backup, { recursive: true, force: true });
|
|
441
446
|
} catch (error) {
|
|
442
|
-
warnings.push(
|
|
447
|
+
warnings.push({
|
|
448
|
+
code: "warning.backupUncleaned",
|
|
449
|
+
params: { path: item.backup, error: String(error && error.message ? error.message : error) },
|
|
450
|
+
error: `旧版本备份未清理: ${item.backup}(${String(error && error.message ? error.message : error)})`,
|
|
451
|
+
});
|
|
443
452
|
}
|
|
444
453
|
}
|
|
445
454
|
return warnings;
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// dsh-skills-manager host half:设置面板的 HTTP 后端(webServer prefix 路由)
|
|
2
2
|
// - 零第三方 import(仅 node: 内置 + 本地 core.js)
|
|
3
3
|
// - 路由:/api/dsh-skills-manager/state | enable | disable | delete | import
|
|
4
|
+
// - 全部接口先校验 loopback Host;写接口再校验请求标记与 JSON
|
|
4
5
|
// - $DSH_HOME\skills 可上传、删除、启停;$DSH_AGENTS_HOME\skills 仅允许查看
|
|
5
6
|
|
|
6
7
|
import { appendFile, rename, rm, stat } from "node:fs/promises";
|
|
@@ -72,10 +73,17 @@ function readBody(req, limit = 1 << 20) {
|
|
|
72
73
|
});
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/**
|
|
76
|
-
function
|
|
76
|
+
/** 只允许 loopback Host,避免 DNS rebinding 读取 /state 或改写本地技能。 */
|
|
77
|
+
function validateLoopbackHost(req) {
|
|
77
78
|
const host = String(req.headers.host || "").toLowerCase();
|
|
78
|
-
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host)) return { statusCode: 403, code: "error.proto.
|
|
79
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host)) return { statusCode: 403, code: "error.proto.forbiddenHost", error: "forbidden host" };
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 写接口额外要求自定义头与 JSON;自定义头迫使跨站 fetch 预检,且本接口不回 CORS。 */
|
|
84
|
+
function validateMutationRequest(req) {
|
|
85
|
+
const hostError = validateLoopbackHost(req);
|
|
86
|
+
if (hostError) return hostError;
|
|
79
87
|
if (req.headers[CLIENT_MARKER_HEADER] !== "1") return { statusCode: 403, code: "error.proto.forbidden", error: "forbidden mutation request" };
|
|
80
88
|
const contentType = String(req.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
|
|
81
89
|
if (contentType !== "application/json") return { statusCode: 415, code: "error.proto.contentType", error: "content-type must be application/json" };
|
|
@@ -174,6 +182,11 @@ function apply(ctx) {
|
|
|
174
182
|
const u = new URL(req.url, "http://localhost");
|
|
175
183
|
const path = u.pathname.replace(/\/+$/, "");
|
|
176
184
|
try {
|
|
185
|
+
const hostError = validateLoopbackHost(req);
|
|
186
|
+
if (hostError) {
|
|
187
|
+
json(res, hostError.statusCode, { ok: false, code: hostError.code, error: hostError.error });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
177
190
|
if (req.method === "GET" && path === "/api/dsh-skills-manager/state") {
|
|
178
191
|
return run(res, state);
|
|
179
192
|
}
|
package/package.json
CHANGED