@michengai/dsh-skills-manager 0.1.1 → 0.1.3

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 CHANGED
@@ -39,6 +39,7 @@
39
39
  - **Safe shared-skill view** — public Agent skills are visible but strictly read-only; their global metadata is never changed.
40
40
  - **Native file selection** — select a plugin `SKILL.md` from the operating system file picker, or explicitly choose its directory when the client cannot expose the selected file path.
41
41
  - **Protected replacement** — a same-name import always requires confirmation, including a flat skill and a bundled skill with the same normalized name.
42
+ - **Safer imports** — importing from the DSH skills directory itself, its parent, or a child path is rejected; symbolic links are also rejected.
42
43
  - **Predictable dialogs** — Escape closes only the active upload or confirmation dialog, leaving the Settings page open.
43
44
 
44
45
  ## Directory permissions
@@ -50,6 +51,12 @@
50
51
 
51
52
  The shared Agent directory is intentionally read-only in both the interface and the server API.
52
53
 
54
+ ## Safety behavior
55
+
56
+ - Enable, disable, and delete only accept one ordinary skill-name path segment; directory traversal names are rejected.
57
+ - Replacements are first copied to a temporary sibling path. Existing files are kept until that copy succeeds.
58
+ - Mutation endpoints require JSON and the DSH client request marker, so a cross-site browser request cannot trigger a local file operation.
59
+
53
60
  ## Quick start
54
61
 
55
62
  You need a working DeepSeek Harness Web installation. Do not run `npm install` in an arbitrary directory: install the plugin into the DSH Web profile instead.
package/README.zh-CN.md CHANGED
@@ -39,6 +39,7 @@
39
39
  - **公共技能安全查看**:公共 Agent 技能可见但严格只读,绝不会改写其全局元数据。
40
40
  - **系统原生选择**:通过操作系统文件选择器选择插件的 `SKILL.md`;客户端无法提供所选文件路径时,可显式选择插件目录。
41
41
  - **覆盖保护**:导入同名技能一定要求确认;flat 与 bundle 两种形态同名时同样如此。
42
+ - **更安全的导入**:拒绝从 DSH 技能目录自身、其父目录或子目录导入,也拒绝符号链接。
42
43
  - **弹窗行为可预期**:按 ESC 只关闭当前上传框或确认框,不会关闭设置页面。
43
44
 
44
45
  ## 目录权限
@@ -50,6 +51,12 @@
50
51
 
51
52
  公共 Agent 目录在界面和服务端均为只读,用于避免误改共享的全局技能元数据。
52
53
 
54
+ ## 安全行为
55
+
56
+ - 启用、停用和删除只接受单个普通技能名称,目录穿越名称会被拒绝。
57
+ - 覆盖前会先复制到同目录临时路径;复制成功前不会触碰现有技能。
58
+ - 写入接口要求 JSON 与 DSH 客户端请求标记,跨站浏览器请求无法触发本地文件操作。
59
+
53
60
  ## 快速开始
54
61
 
55
62
  环境要求:可正常运行的 DeepSeek Harness Web 环境。不要在任意目录执行 `npm install`;应将插件安装到 DSH Web profile。
package/lib/client.js CHANGED
@@ -1,12 +1,13 @@
1
- // dsh-skills-manager client half:DSH 设置中的单目录技能管理面板。
1
+ // dsh-skills-manager client half:DSH 设置中的本地管理与公共 Agent 只读技能面板。
2
2
  window.__ModuleLoader__.load({
3
- id: "dsh-skills-manager",
3
+ id: "@michengai/dsh-skills-manager",
4
4
  factory: (require) => {
5
5
  var module = { exports: {} };
6
6
  Object.defineProperty(module.exports, Symbol.toStringTag, { value: "Module" });
7
7
  var react = require("react");
8
8
  var h = react.createElement;
9
9
  var pickDirectory = null;
10
+ var MUTATION_HEADERS = { "content-type": "application/json", "x-dsh-skills-manager": "1" };
10
11
 
11
12
  var CSS = `
12
13
  .dssm-section{display:flex;min-width:0;flex-direction:column;gap:20px;color:var(--dsw-alias-label-primary)}
@@ -85,7 +86,7 @@ window.__ModuleLoader__.load({
85
86
  function action(path, body) {
86
87
  if (busy) return;
87
88
  setBusy(path);
88
- callApi(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) }).then(function () {
89
+ callApi(path, { method: "POST", headers: MUTATION_HEADERS, body: JSON.stringify(body || {}) }).then(function () {
89
90
  setBusy(null);
90
91
  refresh();
91
92
  }).catch(function (error) {
@@ -135,7 +136,7 @@ window.__ModuleLoader__.load({
135
136
  function executeImport(source, conflict) {
136
137
  setBusy("import");
137
138
  setConfirmImport(null);
138
- callApi("/import", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ source: source, conflict: conflict }) }).then(function (data) {
139
+ callApi("/import", { method: "POST", headers: MUTATION_HEADERS, body: JSON.stringify({ source: source, conflict: conflict }) }).then(function (data) {
139
140
  setBusy(null);
140
141
  setImportResult(data);
141
142
  setSelected(null);
@@ -150,7 +151,7 @@ window.__ModuleLoader__.load({
150
151
  function installSelected() {
151
152
  if (!selected || busy) return;
152
153
  setBusy("import-check");
153
- callApi("/import", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ source: selected.source, dryRun: true }) }).then(function (data) {
154
+ callApi("/import", { method: "POST", headers: MUTATION_HEADERS, body: JSON.stringify({ source: selected.source, dryRun: true }) }).then(function (data) {
154
155
  if (data.conflicts && data.conflicts.length) {
155
156
  setBusy(null);
156
157
  setConfirmImport({ source: selected.source, conflicts: data.conflicts });
@@ -165,14 +166,16 @@ window.__ModuleLoader__.load({
165
166
 
166
167
  function skillRow(skill, root) {
167
168
  var enabled = skill.modelInvocable && skill.userInvocable;
169
+ var policyValid = skill.invocationPolicyValid !== false;
170
+ var actionEnabled = policyValid && enabled;
168
171
  return h("div", { key: skill.name, className: "dssm-row" },
169
172
  h("div", { className: "dssm-row-main" },
170
173
  h("div", { className: "dssm-row-id" },
171
174
  h("span", { className: "dssm-row-name" }, skill.name),
172
175
  h("span", { className: "dssm-tag" }, skill.kind === "bundle" ? "目录插件" : "单文件"),
173
- h("span", { className: "dssm-tag " + (enabled ? "dssm-tag-on" : "dssm-tag-off") }, enabled ? "已启用" : "已停用")),
176
+ h("span", { className: "dssm-tag " + (policyValid && enabled ? "dssm-tag-on" : "dssm-tag-off") }, policyValid ? (enabled ? "已启用" : "已停用") : "配置异常")),
174
177
  h("div", { className: "dssm-note", title: skill.description || "" }, skill.description || "未提供简介")),
175
- root.mutable ? h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: function () { action(enabled ? "/disable" : "/enable", { name: skill.name, root: root.key }); } }, enabled ? "停用" : "启用") : null,
178
+ root.mutable ? h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: function () { action(actionEnabled ? "/disable" : "/enable", { name: skill.name, root: root.key }); } }, actionEnabled ? "停用" : policyValid ? "启用" : "修复并启用") : null,
176
179
  root.mutable ? h("button", { className: "dssm-btn dssm-btn-danger", disabled: !!busy, onClick: function () { setConfirmDelete(skill); } }, "删除") : null);
177
180
  }
178
181
 
@@ -201,8 +204,9 @@ window.__ModuleLoader__.load({
201
204
  if (importResult) {
202
205
  var importedNames = (importResult.imported || []).map(function (item) { return item.name; });
203
206
  var failedItems = importResult.failed || [];
207
+ var skippedNames = (importResult.skipped || []).map(function (item) { return item.name; });
204
208
  var failedText = failedItems.map(function (item) { return item.error; }).join(";");
205
- var resultText = importResult.error ? "上传失败:" + importResult.error : failedItems.length ? (importedNames.length ? "部分上传成功:" + importedNames.join("、") + ";失败:" + failedText : "上传失败:" + failedText) : "上传完成:" + importedNames.join("、");
209
+ var resultText = importResult.error ? "上传失败:" + importResult.error : failedItems.length ? (importedNames.length ? "部分上传成功:" + importedNames.join("、") + ";失败:" + failedText : "上传失败:" + failedText) : importedNames.length ? "上传完成:" + importedNames.join("、") : skippedNames.length ? "未导入:同名插件已跳过:" + skippedNames.join("、") : "未导入任何插件。";
206
210
  nodes.push(h("div", { key: "import-result", className: importResult.error || failedItems.length ? "dssm-error" : "dssm-note", role: "status" }, resultText));
207
211
  }
208
212
  if (uploadOpen) {
package/lib/core.js CHANGED
@@ -5,13 +5,16 @@
5
5
  // - 条目形态:<root>/<name>/SKILL.md(bundle)或 <root>/<name>.md(flat),只扫一层
6
6
  // - 前端展示 name、description 与启停状态,不做格式检查或自动修复
7
7
  //
8
- // 所有函数返回普通结果对象,失败时返回 { ok: false, error };写文件/日志失败静默降级。
8
+ // 所有函数返回普通结果对象,业务校验失败返回 { ok: false, error };文件写入错误由路由返回给调用方。
9
9
 
10
10
  import { homedir } from "node:os";
11
- import { join, basename, dirname } from "node:path";
11
+ import { join, basename, dirname, resolve, relative, isAbsolute, sep } from "node:path";
12
12
  import { promises as fs } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
13
14
 
14
15
  const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
+ const WINDOWS_DEVICE_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
17
+ const MAX_SOURCE_DEPTH = 64;
15
18
 
16
19
  // ── 路径解析 ────────────────────────────────────────────────────────────────
17
20
 
@@ -35,6 +38,29 @@ export function logPath() {
35
38
  return join(resolveDshHome(), "dsh-skills-manager.log");
36
39
  }
37
40
 
41
+ function dshRootPath() {
42
+ return userRoots().find((root) => root.key === "dsh").path;
43
+ }
44
+
45
+ /** 判断 child 是否与 parent 相同或位于其内部。跨盘符时 relative 会返回绝对路径。 */
46
+ function isSameOrDescendant(parent, child) {
47
+ const rel = relative(resolve(parent), resolve(child));
48
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
49
+ }
50
+
51
+ /** 两个路径重叠时,覆盖导入可能删除自身来源,必须拒绝。 */
52
+ function pathsOverlap(a, b) {
53
+ return isSameOrDescendant(a, b) || isSameOrDescendant(b, a);
54
+ }
55
+
56
+ /** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
57
+ function entryPath(root, name) {
58
+ if (typeof name !== "string" || name === "" || name === "." || name === ".." || /[\\/\0]/.test(name) || /[. ]$/.test(name) || WINDOWS_DEVICE_NAME_RE.test(name) || basename(name) !== name) return null;
59
+ const rootPath = resolve(root);
60
+ const path = resolve(rootPath, name);
61
+ return isSameOrDescendant(rootPath, path) && rootPath !== path ? path : null;
62
+ }
63
+
38
64
  // ── 命名规整 ────────────────────────────────────────────────────────────────
39
65
 
40
66
  /** 尽量把任意名称规整为 kebab-case;无法生成合法名称时返回空串。 */
@@ -81,9 +107,11 @@ export function parseSkillDoc(text) {
81
107
  const content = [];
82
108
  while (i + 1 < end && (/^\s/.test(lines[i + 1]) || lines[i + 1] === "")) {
83
109
  i++;
84
- content.push(lines[i].replace(/^\s{1,2}/, ""));
110
+ content.push(lines[i]);
85
111
  }
86
- map[m[1]] = block[1] === ">" ? content.join(" ").replace(/\s+/g, " ").trim() : content.join("\n").trim();
112
+ const indentation = content.filter((line) => line.trim() !== "").reduce((min, line) => Math.min(min, (/^\s*/.exec(line) || [""])[0].length), Infinity);
113
+ const normalized = content.map((line) => Number.isFinite(indentation) ? line.slice(Math.min(indentation, line.length)) : line);
114
+ map[m[1]] = block[1] === ">" ? normalized.join(" ").replace(/\s+/g, " ").trim() : normalized.join("\n").trim();
87
115
  } else {
88
116
  map[m[1]] = decodeYamlScalar(m[2]);
89
117
  }
@@ -118,42 +146,31 @@ export function unquote(v) {
118
146
  return s;
119
147
  }
120
148
 
121
- /** 把 JS 值序列化为 YAML 标量;需要转义的字符串用 JSON 引号形式。 */
122
- export function yamlScalar(v) {
123
- if (v === true) return "true";
124
- if (v === false) return "false";
125
- if (v === null || v === undefined) return "null";
126
- if (typeof v === "number") return String(v);
127
- const s = String(v);
128
- if (s === "") return '""';
129
- if (/^(true|false|null|yes|no|on|off|~)$/i.test(s) || /^[-+]?\d+(\.\d+)?$/.test(s)) return JSON.stringify(s);
130
- if (/[:#{}[\]&*,?|>%@`"'\s]/.test(s) || s !== s.trim()) return JSON.stringify(s);
131
- return s;
132
- }
133
-
134
- export function serializeSkillDoc(map, body) {
135
- let out = "---\n";
136
- for (const key of Object.keys(map)) {
137
- out += `${key}: ${yamlScalar(map[key])}\n`;
138
- }
139
- out += "---\n";
140
- out += body;
141
- return out;
142
- }
143
-
144
149
  /** 仅更新调用策略行,避免重写并破坏其他 YAML frontmatter。 */
145
150
  function updateInvocationPolicy(text, enabled) {
146
151
  const source = String(text);
147
152
  const newline = source.includes("\r\n") ? "\r\n" : "\n";
148
153
  const lines = source.split(/\r?\n/);
149
- if (lines[0]?.trim() !== "---") return source;
154
+ if (lines[0]?.trim() !== "---") return null;
150
155
  const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
151
- if (end < 0) return source;
156
+ if (end < 0) return null;
152
157
  const fields = lines.slice(1, end).filter((line) => !/^(disable-model-invocation|user-invocable)\s*:/.test(line));
153
158
  if (!enabled) fields.push("disable-model-invocation: true", "user-invocable: false");
154
159
  return ["---", ...fields, "---", ...lines.slice(end + 1)].join(newline);
155
160
  }
156
161
 
162
+ /** 同目录临时文件加 rename,避免写入中断时截断原 SKILL.md。 */
163
+ async function writeFileAtomically(path, content) {
164
+ const temp = join(dirname(path), `.${basename(path)}.dssm-${randomUUID()}.tmp`);
165
+ try {
166
+ await fs.writeFile(temp, content, "utf8");
167
+ await fs.rename(temp, path);
168
+ } catch (error) {
169
+ await fs.rm(temp, { force: true }).catch(() => undefined);
170
+ throw error;
171
+ }
172
+ }
173
+
157
174
  /** 解析布尔字段值;合法布尔返回 true/false,非法返回 undefined。 */
158
175
  export function parseBoolValue(raw) {
159
176
  const v = unquote(raw).trim().toLowerCase();
@@ -166,24 +183,30 @@ export function parseBoolValue(raw) {
166
183
 
167
184
  /** 按名称解析条目(bundle 优先,其次 flat)。找不到返回 null。 */
168
185
  export async function resolveEntry(root, name) {
169
- if (typeof name !== "string" || name === "" || basename(name) !== name) return null;
170
- const bundleDoc = join(root, name, "SKILL.md");
186
+ const bundlePath = entryPath(root, name);
187
+ if (bundlePath === null) return null;
188
+ const rootPath = resolve(root);
189
+ const bundleDoc = join(bundlePath, "SKILL.md");
171
190
  try {
172
191
  const st = await fs.stat(bundleDoc);
173
- if (st.isFile()) return { kind: "bundle", docPath: bundleDoc };
192
+ if (st.isFile()) return { kind: "bundle", docPath: bundleDoc, entryPath: bundlePath };
174
193
  } catch {}
175
- const flatDoc = join(root, `${name}.md`);
194
+ const flatDoc = resolve(rootPath, `${name}.md`);
195
+ if (!isSameOrDescendant(rootPath, flatDoc) || rootPath === flatDoc) return null;
176
196
  try {
177
197
  const st = await fs.stat(flatDoc);
178
- if (st.isFile()) return { kind: "flat", docPath: flatDoc };
198
+ if (st.isFile()) return { kind: "flat", docPath: flatDoc, entryPath: flatDoc };
179
199
  } catch {}
180
200
  return null;
181
201
  }
182
202
 
183
203
  function entryOf(name, kind, docPath, doc) {
184
204
  const description = doc.map.description !== undefined ? unquote(doc.map.description) : "";
185
- const modelDisabled = parseBoolValue(doc.map["disable-model-invocation"]) === true;
186
- const userDisabled = parseBoolValue(doc.map["user-invocable"]) === false;
205
+ const modelValue = parseBoolValue(doc.map["disable-model-invocation"]);
206
+ const userValue = parseBoolValue(doc.map["user-invocable"]);
207
+ const modelDisabled = modelValue === true;
208
+ const userDisabled = userValue === false;
209
+ const invocationPolicyValid = (doc.map["disable-model-invocation"] === undefined || modelValue !== undefined) && (doc.map["user-invocable"] === undefined || userValue !== undefined);
187
210
  return {
188
211
  name,
189
212
  kind,
@@ -191,6 +214,7 @@ function entryOf(name, kind, docPath, doc) {
191
214
  description,
192
215
  modelInvocable: !modelDisabled,
193
216
  userInvocable: !userDisabled,
217
+ invocationPolicyValid,
194
218
  };
195
219
  }
196
220
 
@@ -211,7 +235,7 @@ export async function scanEntries(root) {
211
235
  if (!st.isFile()) continue;
212
236
  const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
213
237
  entries.push(entryOf(it.name, "bundle", docPath, doc));
214
- } else if (it.isFile() && it.name.endsWith(".md") && it.name !== "SKILL.md") {
238
+ } else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md") {
215
239
  const docPath = join(root, it.name);
216
240
  const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
217
241
  entries.push(entryOf(it.name.slice(0, -3), "flat", docPath, doc));
@@ -228,22 +252,23 @@ export async function scanEntries(root) {
228
252
 
229
253
  /** enabled=true 恢复模型与 / 手动调用;false 同时停用两种调用入口。 */
230
254
  export async function setSkillEnabled(root, name, enabled) {
231
- if (root !== userRoots()[0].path) return { ok: false, error: "公共 Agent 技能目录不允许启用或停用" };
255
+ if (root !== dshRootPath()) return { ok: false, error: "公共 Agent 技能目录不允许启用或停用" };
232
256
  const resolved = await resolveEntry(root, name);
233
257
  if (resolved === null) return { ok: false, error: `技能不存在: ${name}` };
234
258
  const source = await fs.readFile(resolved.docPath, "utf8");
235
- await fs.writeFile(resolved.docPath, updateInvocationPolicy(source, enabled), "utf8");
259
+ const updated = updateInvocationPolicy(source, enabled);
260
+ if (updated === null) return { ok: false, error: `技能缺少完整 frontmatter,无法${enabled ? "启用" : "停用"}: ${name}` };
261
+ await writeFileAtomically(resolved.docPath, updated);
236
262
  return { name, enabled };
237
263
  }
238
264
 
239
265
  /** 删除 DSH 根目录中的单个技能。调用方必须先向用户确认。 */
240
266
  export async function deleteSkill(root, name, log) {
241
- if (root !== userRoots()[0].path) return { ok: false, error: "公共 Agent 技能目录不允许删除" };
267
+ if (root !== dshRootPath()) return { ok: false, error: "公共 Agent 技能目录不允许删除" };
242
268
  const resolved = await resolveEntry(root, name);
243
269
  if (resolved === null) return { ok: false, error: `技能不存在: ${name}` };
244
- const entryPath = resolved.kind === "bundle" ? join(root, name) : resolved.docPath;
245
- await fs.rm(entryPath, { recursive: resolved.kind === "bundle", force: true });
246
- if (log) log("delete", `删除 ${entryPath}`);
270
+ await fs.rm(resolved.entryPath, { recursive: resolved.kind === "bundle", force: true });
271
+ if (log) log("delete", `删除 ${resolved.entryPath}`);
247
272
  return { name };
248
273
  }
249
274
 
@@ -253,10 +278,11 @@ export async function deleteSkill(root, name, log) {
253
278
  async function analyzeSource(source) {
254
279
  let st;
255
280
  try {
256
- st = await fs.stat(source);
281
+ st = await fs.lstat(source);
257
282
  } catch {
258
283
  return { kind: "none", error: `路径不存在: ${source}` };
259
284
  }
285
+ if (st.isSymbolicLink()) return { kind: "none", error: `不支持包含符号链接的 skill 来源: ${source}` };
260
286
  if (st.isDirectory()) {
261
287
  try {
262
288
  const sk = join(source, "SKILL.md");
@@ -282,6 +308,7 @@ async function collectCandidates(dir) {
282
308
  const items = await fs.readdir(dir, { withFileTypes: true });
283
309
  const out = [];
284
310
  for (const it of items) {
311
+ if (it.isSymbolicLink()) throw new Error(`不支持包含符号链接的 skill 来源: ${join(dir, it.name)}`);
285
312
  try {
286
313
  if (it.isDirectory()) {
287
314
  const sk = join(dir, it.name, "SKILL.md");
@@ -289,7 +316,7 @@ async function collectCandidates(dir) {
289
316
  if (st.isFile()) {
290
317
  out.push({ source: join(dir, it.name), kebab: toKebab(it.name), rawName: it.name, isDir: true });
291
318
  }
292
- } else if (it.isFile() && it.name.endsWith(".md") && it.name !== "SKILL.md") {
319
+ } else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md") {
293
320
  out.push({ source: join(dir, it.name), kebab: toKebab(it.name.slice(0, -3)), rawName: it.name.slice(0, -3), isDir: false });
294
321
  }
295
322
  } catch {
@@ -299,24 +326,92 @@ async function collectCandidates(dir) {
299
326
  return out;
300
327
  }
301
328
 
329
+ /** 导入内容不接受符号链接,避免把目标目录外的内容带入技能目录。 */
330
+ async function assertNoSymbolicLinks(source) {
331
+ const pending = [{ path: source, depth: 0 }];
332
+ while (pending.length) {
333
+ const current = pending.pop();
334
+ if (current.depth > MAX_SOURCE_DEPTH) throw new Error(`skill 来源目录层级超过 ${MAX_SOURCE_DEPTH} 层: ${source}`);
335
+ const st = await fs.lstat(current.path);
336
+ if (st.isSymbolicLink()) throw new Error(`不支持包含符号链接的 skill 来源: ${current.path}`);
337
+ if (!st.isDirectory()) continue;
338
+ const items = await fs.readdir(current.path, { withFileTypes: true });
339
+ for (const item of items) {
340
+ const path = join(current.path, item.name);
341
+ if (item.isSymbolicLink()) throw new Error(`不支持包含符号链接的 skill 来源: ${path}`);
342
+ if (item.isDirectory()) pending.push({ path, depth: current.depth + 1 });
343
+ }
344
+ }
345
+ }
346
+
347
+ function temporaryPath(target, kind) {
348
+ return join(dirname(target), `.${basename(target)}.dssm-${kind}-${randomUUID()}`);
349
+ }
350
+
351
+ /** 先复制到同目录临时路径,复制失败时不触碰现有技能。 */
352
+ async function copyToTemporary(source, target, isDir) {
353
+ const temp = temporaryPath(target, "stage");
354
+ try {
355
+ await assertNoSymbolicLinks(source);
356
+ if (isDir) await fs.cp(source, temp, { recursive: true, dereference: false });
357
+ else await fs.copyFile(source, temp);
358
+ return temp;
359
+ } catch (error) {
360
+ await fs.rm(temp, { recursive: true, force: true }).catch(() => undefined);
361
+ throw error;
362
+ }
363
+ }
364
+
365
+ /** 临时副本就绪后再替换;替换失败时尽力恢复旧条目。 */
366
+ async function replaceWithCopy(source, dest, isDir, existing = []) {
367
+ const stage = await copyToTemporary(source, dest, isDir);
368
+ const backups = [];
369
+ try {
370
+ for (const path of existing) {
371
+ const backup = temporaryPath(path, "backup");
372
+ await fs.rename(path, backup);
373
+ backups.push({ path, backup });
374
+ }
375
+ await fs.rename(stage, dest);
376
+ } catch (error) {
377
+ await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
378
+ for (const item of backups.reverse()) await fs.rename(item.backup, item.path).catch(() => undefined);
379
+ throw error;
380
+ }
381
+ const warnings = [];
382
+ for (const item of backups) {
383
+ try {
384
+ await fs.rm(item.backup, { recursive: true, force: true });
385
+ } catch (error) {
386
+ warnings.push(`旧版本备份未清理: ${item.backup}(${String(error && error.message ? error.message : error)})`);
387
+ }
388
+ }
389
+ return warnings;
390
+ }
391
+
302
392
  /**
303
393
  * 导入技能到目标根。
304
394
  * options: { conflict: 'skip'|'overwrite', dryRun: boolean }
305
395
  * 成功返回 { kind, imported, skipped, failed };失败返回 { ok:false, error }。
306
396
  */
307
397
  export async function importSkill(source, log, options = {}) {
308
- const targetRoot = userRoots()[0].path;
398
+ const targetRoot = dshRootPath();
309
399
  const conflict = options.conflict === "overwrite" ? "overwrite" : "skip";
310
400
  const dryRun = options.dryRun === true;
311
401
 
312
402
  const analysis = await analyzeSource(source);
313
403
  if (analysis.kind === "none") return { ok: false, error: analysis.error || "无法识别的 skill 来源" };
404
+ if (pathsOverlap(analysis.source, targetRoot)) return { ok: false, error: "导入来源不能与 DSH 技能目录相同、包含或位于其中" };
314
405
 
315
406
  let candidates = [];
316
407
  if (analysis.kind === "single") {
317
408
  candidates = [{ source: analysis.source, kebab: analysis.kebab, rawName: analysis.rawName, isDir: analysis.isDir }];
318
409
  } else {
319
- candidates = await collectCandidates(source);
410
+ try {
411
+ candidates = await collectCandidates(source);
412
+ } catch (error) {
413
+ return { ok: false, error: String(error && error.message ? error.message : error) };
414
+ }
320
415
  if (candidates.length === 0) return { ok: false, error: `目录下未找到任何 skill 条目(需含 SKILL.md 的子目录或 .md 文件): ${source}` };
321
416
  }
322
417
 
@@ -326,6 +421,11 @@ export async function importSkill(source, log, options = {}) {
326
421
  const imported = [];
327
422
  const skipped = [];
328
423
 
424
+ const nameCount = new Map();
425
+ for (const candidate of candidates) {
426
+ if (candidate.kebab && KEBAB_RE.test(candidate.kebab)) nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
427
+ }
428
+
329
429
  function failureResult() {
330
430
  return {
331
431
  ok: false,
@@ -342,6 +442,10 @@ export async function importSkill(source, log, options = {}) {
342
442
  failed.push({ source: c.source, error: `无法生成合法 kebab-case 名称(原始名: ${c.rawName || basename(c.source)})` });
343
443
  continue;
344
444
  }
445
+ if (nameCount.get(c.kebab) > 1) {
446
+ failed.push({ source: c.source, error: `批量来源中存在多个同名插件: ${c.kebab}` });
447
+ continue;
448
+ }
345
449
  const dest = c.isDir ? join(targetRoot, c.kebab) : join(targetRoot, `${c.kebab}.md`);
346
450
  const paths = [join(targetRoot, c.kebab), join(targetRoot, `${c.kebab}.md`)];
347
451
  const existing = [];
@@ -367,9 +471,8 @@ export async function importSkill(source, log, options = {}) {
367
471
 
368
472
  for (const p of pending) {
369
473
  try {
370
- if (p.isDir) await fs.cp(p.source, p.dest, { recursive: true });
371
- else await fs.copyFile(p.source, p.dest);
372
- imported.push({ name: p.name, overwritten: false, warnings: [] });
474
+ const warnings = await replaceWithCopy(p.source, p.dest, p.isDir);
475
+ imported.push({ name: p.name, overwritten: false, warnings });
373
476
  if (log) log("import", `导入 ${p.source} -> ${p.dest}`);
374
477
  } catch (e) {
375
478
  failed.push({ source: p.source, error: String(e && e.message ? e.message : e) });
@@ -380,10 +483,8 @@ export async function importSkill(source, log, options = {}) {
380
483
  for (const c of conflicts) {
381
484
  try {
382
485
  const dest = c.isDir ? join(targetRoot, c.name) : join(targetRoot, `${c.name}.md`);
383
- for (const path of c.paths) await fs.rm(path, { recursive: true, force: true });
384
- if (c.isDir) await fs.cp(c.source, dest, { recursive: true });
385
- else await fs.copyFile(c.source, dest);
386
- imported.push({ name: c.name, overwritten: true, warnings: [] });
486
+ const warnings = await replaceWithCopy(c.source, dest, c.isDir, c.paths);
487
+ imported.push({ name: c.name, overwritten: true, warnings });
387
488
  if (log) log("import-overwrite", `覆盖导入 ${c.source} -> ${dest}`);
388
489
  } catch (e) {
389
490
  failed.push({ source: c.source, error: String(e && e.message ? e.message : e) });
@@ -413,6 +514,7 @@ export async function state() {
413
514
  description: e.description,
414
515
  modelInvocable: e.modelInvocable,
415
516
  userInvocable: e.userInvocable,
517
+ invocationPolicyValid: e.invocationPolicyValid,
416
518
  });
417
519
  }
418
520
  result.roots.push({ key: root.key, path: root.path, label: root.label, mutable: root.mutable, exists, skills });
package/lib/index.js CHANGED
@@ -3,48 +3,81 @@
3
3
  // - 路由:/api/dsh-skills-manager/state | enable | disable | delete | import
4
4
  // - $DSH_HOME\skills 可上传、删除、启停;$DSH_AGENTS_HOME\skills 仅允许查看
5
5
 
6
- import { appendFile } from "node:fs/promises";
6
+ import { appendFile, rename, rm, stat } from "node:fs/promises";
7
7
  import { state, setSkillEnabled, deleteSkill, importSkill, userRoots, logPath } from "./core.js";
8
8
 
9
9
  const name = "skills-manager";
10
10
  const inject = ["webServer", "skills"];
11
+ const CLIENT_MARKER_HEADER = "x-dsh-skills-manager";
12
+ const MAX_LOG_BYTES = 1 << 20;
11
13
 
12
14
  function makeLog() {
13
15
  const file = logPath();
16
+ let queue = Promise.resolve();
14
17
  return async (event, detail) => {
15
- try {
16
- await appendFile(file, `${JSON.stringify({ ts: new Date().toISOString(), event, detail })}\n`, "utf8");
17
- } catch {
18
- /* 日志失败不阻塞主流程 */
19
- }
18
+ queue = queue.then(async () => {
19
+ try {
20
+ const current = await stat(file).catch(() => null);
21
+ if (current && current.size >= MAX_LOG_BYTES) {
22
+ await rm(`${file}.1`, { force: true });
23
+ await rename(file, `${file}.1`);
24
+ }
25
+ await appendFile(file, `${JSON.stringify({ ts: new Date().toISOString(), event, detail })}\n`, "utf8");
26
+ } catch {
27
+ /* 日志失败不阻塞主流程 */
28
+ }
29
+ });
30
+ await queue;
20
31
  };
21
32
  }
22
33
 
23
34
  function readBody(req, limit = 1 << 20) {
24
35
  return new Promise((resolve, reject) => {
25
36
  let size = 0;
37
+ let settled = false;
26
38
  const chunks = [];
39
+ const fail = (error) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ reject(error);
43
+ };
27
44
  req.on("data", (c) => {
45
+ if (settled) return;
28
46
  size += c.length;
29
47
  if (size > limit) {
30
- reject(new Error("body too large"));
31
- req.destroy();
48
+ const error = new Error("body too large");
49
+ error.statusCode = 413;
50
+ fail(error);
51
+ req.resume();
32
52
  return;
33
53
  }
34
54
  chunks.push(c);
35
55
  });
36
56
  req.on("end", () => {
57
+ if (settled) return;
37
58
  try {
38
59
  const raw = Buffer.concat(chunks).toString("utf8");
39
- resolve(raw ? JSON.parse(raw) : {});
60
+ const body = raw ? JSON.parse(raw) : {};
61
+ settled = true;
62
+ resolve(body);
40
63
  } catch (e) {
41
- reject(new Error(`invalid JSON body: ${e.message}`));
64
+ const error = new Error(`invalid JSON body: ${e.message}`);
65
+ error.statusCode = 400;
66
+ fail(error);
42
67
  }
43
68
  });
44
- req.on("error", reject);
69
+ req.on("error", fail);
45
70
  });
46
71
  }
47
72
 
73
+ /** 自定义请求头使跨站 fetch 必须预检;本地接口不提供 CORS 响应。 */
74
+ function validateMutationRequest(req) {
75
+ if (req.headers[CLIENT_MARKER_HEADER] !== "1") return { statusCode: 403, error: "forbidden mutation request" };
76
+ const contentType = String(req.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
77
+ if (contentType !== "application/json") return { statusCode: 415, error: "content-type must be application/json" };
78
+ return null;
79
+ }
80
+
48
81
  function json(res, code, payload) {
49
82
  const body = JSON.stringify(payload);
50
83
  res.writeHead(code, {
@@ -104,6 +137,11 @@ function apply(ctx) {
104
137
  json(res, 405, { ok: false, error: `method not allowed: ${req.method}` });
105
138
  return;
106
139
  }
140
+ const requestError = validateMutationRequest(req);
141
+ if (requestError) {
142
+ json(res, requestError.statusCode, { ok: false, error: requestError.error });
143
+ return;
144
+ }
107
145
  const body = await readBody(req);
108
146
  switch (path) {
109
147
  case "/api/dsh-skills-manager/enable": {
@@ -128,7 +166,7 @@ function apply(ctx) {
128
166
  json(res, 404, { ok: false, error: `unknown action: ${path}` });
129
167
  }
130
168
  } catch (e) {
131
- json(res, 500, { ok: false, error: String(e && e.message ? e.message : e) });
169
+ json(res, Number.isInteger(e && e.statusCode) ? e.statusCode : 500, { ok: false, error: String(e && e.message ? e.message : e) });
132
170
  }
133
171
  },
134
172
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-skills-manager",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "NPM-installable DSH Web plugin for managing local skills and viewing shared Agent skills safely.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -47,9 +47,12 @@
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@deepseek-ai/cordis": ">=4.0.1 <5.0.0",
50
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.0 <0.2.0",
51
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.0 <0.2.0",
50
52
  "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.5",
51
53
  "@deepseek-ai/dsh-skill": ">=0.1.0-rc.5",
52
- "@deepseek-ai/dsh-client-ui-workspace": ">=0.1.0-rc.5"
54
+ "@deepseek-ai/dsh-client-ui-workspace": ">=0.1.0-rc.5",
55
+ "react": "^18.2.0"
53
56
  },
54
57
  "engines": {
55
58
  "node": ">=20"