@webskill/sdk 0.1.3 → 0.1.5

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.
@@ -1,15 +1,15 @@
1
- import { E as verifyManifest, T as validateSkills, _ as isValidSkillName, b as parseSkillMarkdown, d as buildManifest, g as exportSkills, l as WebSkillError, n as SKILLS_LOCKFILE, o as SKILL_PACK_FILE, r as SKILL_MANIFEST_FILE, w as resolveInsideRoot, x as parseSkillPackManifest } from "./dist-DS1sfgHa.js";
2
- import { M as normalizeToolContent, N as parseBridgeRequest, S as bridgeError, c as FsArtifactStore, j as networkUrlHost, k as isNetworkAllowed, l as FsMemoryStore, o as CapabilityApproval } from "./dist-DZobLFh6.js";
1
+ import { A as validateSkills, C as parseSkillPackManifest, D as resolveArchiveLimits, O as resolveInsideRoot, S as parseSkillMarkdown, i as SKILL_MANIFEST_FILE, j as verifyManifest, k as unzipWithLimits, p as buildManifest, r as SKILLS_LOCKFILE, s as SKILL_PACK_FILE, u as WebSkillError, v as exportSkills, w as readResponseWithLimit, y as isValidSkillName } from "./dist-fZFZaf43.js";
2
+ import { M as normalizeToolContent, N as parseBridgeRequest, S as bridgeError, c as FsArtifactStore, j as networkUrlHost, k as isNetworkAllowed, l as FsMemoryStore, o as CapabilityApproval } from "./dist-DuGN5x0v.js";
3
3
  import { unzipSync, zipSync } from "fflate";
4
4
  import { existsSync, promises, readFileSync } from "node:fs";
5
5
  import path from "node:path";
6
+ import { tmpdir } from "node:os";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import { format, promisify } from "node:util";
8
9
  import { Worker } from "node:worker_threads";
9
10
  import { parseSync } from "oxc-parser";
10
11
  import { createInterface } from "node:readline/promises";
11
12
  import { mkdtemp } from "node:fs/promises";
12
- import { tmpdir } from "node:os";
13
13
  import * as tar from "tar";
14
14
  import { execFile } from "node:child_process";
15
15
  import { createHash } from "node:crypto";
@@ -18,10 +18,41 @@ import { createHash } from "node:crypto";
18
18
  const toPlatform$3 = (p) => p.split("/").join(path.sep);
19
19
  const toPosix = (p) => p.split(path.sep).join("/");
20
20
  const isEnoent = (e) => typeof e === "object" && e !== null && e.code === "ENOENT";
21
- /** 基于 node:fs/promises 的 FileSystemProvider;接受 `/` 风格路径,写入自动创建父目录 */
21
+ /** realpath 最近现存祖先并拼回剩余段(写入目标尚不存在时的等价判定) */
22
+ async function realpathNearest(platformPath) {
23
+ let current = platformPath;
24
+ const rest = [];
25
+ for (;;) try {
26
+ const real = await promises.realpath(current);
27
+ return rest.length === 0 ? real : [real, ...rest].join(path.sep);
28
+ } catch (e) {
29
+ if (!isEnoent(e)) throw e;
30
+ rest.unshift(path.basename(current));
31
+ const parent = path.dirname(current);
32
+ if (parent === current) return platformPath;
33
+ current = parent;
34
+ }
35
+ }
36
+ /**
37
+ * 基于 node:fs/promises 的 FileSystemProvider;接受 `/` 风格路径,写入自动创建父目录。
38
+ * 可选 root 模式:构造传入 root 后,read/write 操作先做 realpath 包含校验
39
+ * (root 与目标都 realpath 后前缀比对),经符号链接逃逸 root → FS_PATH_OUTSIDE_ROOT。
40
+ */
22
41
  var NodeFS = class {
23
42
  kind = "node";
43
+ #root;
44
+ constructor(deps = {}) {
45
+ this.#root = deps.root;
46
+ }
47
+ /** root 模式:目标 realpath 必须落在 root realpath 前缀内(符号链接逃逸防护) */
48
+ async #assertContained(p) {
49
+ if (!this.#root) return;
50
+ const rootReal = await promises.realpath(toPlatform$3(this.#root));
51
+ const targetReal = await realpathNearest(toPlatform$3(p));
52
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + path.sep)) throw new WebSkillError("FS_PATH_OUTSIDE_ROOT", `Path escapes root via symbolic link: ${p} (resolves outside ${this.#root})`);
53
+ }
24
54
  async readText(p) {
55
+ await this.#assertContained(p);
25
56
  try {
26
57
  return await promises.readFile(toPlatform$3(p), "utf8");
27
58
  } catch (e) {
@@ -29,11 +60,13 @@ var NodeFS = class {
29
60
  }
30
61
  }
31
62
  async writeText(p, content) {
63
+ await this.#assertContained(p);
32
64
  const target = toPlatform$3(p);
33
65
  await promises.mkdir(path.dirname(target), { recursive: true });
34
66
  await promises.writeFile(target, content, "utf8");
35
67
  }
36
68
  async readBinary(p) {
69
+ await this.#assertContained(p);
37
70
  try {
38
71
  return await promises.readFile(toPlatform$3(p));
39
72
  } catch (e) {
@@ -41,6 +74,7 @@ var NodeFS = class {
41
74
  }
42
75
  }
43
76
  async writeBinary(p, content) {
77
+ await this.#assertContained(p);
44
78
  const target = toPlatform$3(p);
45
79
  await promises.mkdir(path.dirname(target), { recursive: true });
46
80
  await promises.writeFile(target, content);
@@ -94,7 +128,6 @@ var NodeFS = class {
94
128
  }
95
129
  };
96
130
  const baseName$1 = (p) => p.split("/").pop() ?? p;
97
- const toPlatform$2 = (p) => p.split("/").join(path.sep);
98
131
  /**
99
132
  * 进程内脚本执行器。接口按沙箱语义设计(context 无隐式宿主访问),
100
133
  * worker_threads 沙箱见 deferred-items D1。
@@ -114,7 +147,22 @@ var NodeScriptExecutor = class {
114
147
  return hasTs ? tsPath : jsPath;
115
148
  }
116
149
  async #loadModule(scriptPath) {
117
- return await import(`${pathToFileURL(toPlatform$2(scriptPath)).href}?t=${Date.now()}`);
150
+ if (scriptPath.endsWith(".ts")) {
151
+ const source = await this.#fs.readText(scriptPath);
152
+ const dir = await promises.mkdtemp(path.join(tmpdir(), "webskill-ts-"));
153
+ try {
154
+ const file = path.join(dir, "script.ts");
155
+ await promises.writeFile(file, source);
156
+ return await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
157
+ } finally {
158
+ await promises.rm(dir, {
159
+ recursive: true,
160
+ force: true
161
+ });
162
+ }
163
+ }
164
+ const source = await this.#fs.readText(scriptPath);
165
+ return await import(`data:text/javascript;charset=utf-8,${encodeURIComponent(`${source}\n//# ${Date.now()}`)}`);
118
166
  }
119
167
  async loadDefinition(skillRoot, scriptName) {
120
168
  const scriptPath = await this.#locateScript(skillRoot, scriptName);
@@ -223,14 +271,14 @@ var NodeScriptExecutor = class {
223
271
  }
224
272
  };
225
273
  const baseName = (p) => p.split("/").pop() ?? p;
226
- const toPlatform$1 = (p) => p.split("/").join(path.sep);
274
+ const toPlatform$2 = (p) => p.split("/").join(path.sep);
227
275
  const messageOf$5 = (e) => e instanceof Error ? e.message : String(e);
228
276
  /**
229
277
  * 网络策略判定函数源码(注入 Worker;匹配逻辑单一来源在
230
278
  * runtime/sandbox/networkPolicy.ts,Worker 线程不走 vitest 别名故注入而非 import)
231
279
  */
232
280
  const NETWORK_POLICY_LIB = `${isNetworkAllowed.toString()}\n${networkUrlHost.toString()}`;
233
- /** Worker 入口文件:src 为同目录 .ts;dist 为 dist/executor/ 下的 .js */
281
+ /** Worker 入口文件:src 为同目录 .ts;node dist 为 dist/executor/;sdk dist 为平铺 dist/ */
234
282
  function workerEntryPath() {
235
283
  const dir = path.dirname(fileURLToPath(import.meta.url));
236
284
  const candidates = [
@@ -239,13 +287,17 @@ function workerEntryPath() {
239
287
  path.join(dir, "executor", "sandboxWorkerEntry.js")
240
288
  ];
241
289
  for (const candidate of candidates) if (existsSync(candidate)) return candidate;
242
- return candidates[0];
290
+ throw new WebSkillError("TOOL_EXECUTION_FAILED", `Sandbox worker entry not found (tried: ${candidates.join(", ")})`);
243
291
  }
244
292
  /**
245
293
  * D1:worker_threads 沙箱脚本执行器。
246
294
  * 每次执行独立 Worker(resourceLimits + env 白名单),loadDefinition 同样在
247
295
  * Worker 内完成(禁止主进程 import 不可信脚本);超时 terminate(可杀同步死循环)。
248
296
  * 能力桥协议与浏览器同一来源(runtime/sandbox/bridgeProtocol)。
297
+ *
298
+ * 诚实标注:本执行器做的是**能力面收敛**(网络策略、模块 allowlist、资源限额、
299
+ * 超时强杀),**不是安全边界**——Worker 内脚本与宿主共享进程,仍有绕过手段;
300
+ * 禁止假定其可隔离不可信脚本。
249
301
  */
250
302
  var SandboxedScriptExecutor = class {
251
303
  #fs;
@@ -280,8 +332,9 @@ var SandboxedScriptExecutor = class {
280
332
  const skillName = baseName(skillRoot);
281
333
  const result = await this.#runWorker({
282
334
  mode: "load",
283
- scriptPath: toPlatform$1(scriptPath),
284
- scriptName
335
+ scriptPath: toPlatform$2(scriptPath),
336
+ scriptName,
337
+ scriptSource: await this.#fs.readText(scriptPath)
285
338
  });
286
339
  if (!result.ok) throw new WebSkillError("TOOL_EXECUTION_FAILED", `Failed to load script "${scriptName}": ${result.error?.message ?? "unknown error"}`);
287
340
  return {
@@ -310,11 +363,12 @@ var SandboxedScriptExecutor = class {
310
363
  try {
311
364
  const result = await this.#runWorker({
312
365
  mode: "execute",
313
- scriptPath: toPlatform$1(scriptPath),
366
+ scriptPath: toPlatform$2(scriptPath),
314
367
  scriptName,
315
368
  skillName: context.skillName,
316
369
  runId: context.runId,
317
- args
370
+ args,
371
+ scriptSource: await this.#fs.readText(scriptPath)
318
372
  }, timeoutMs, (request) => this.#handleBridge(request, context), (host) => context.onWarning?.(`Network request blocked by sandbox network policy: ${host}`));
319
373
  if (!result.ok) {
320
374
  const stderrSummary = result.stderr?.length ? ` | stderr: ${result.stderr.join(" | ").slice(0, 500)}` : "";
@@ -356,7 +410,8 @@ var SandboxedScriptExecutor = class {
356
410
  workerData: {
357
411
  ...workerData,
358
412
  networkPolicy: this.#networkPolicy,
359
- networkPolicyLib: NETWORK_POLICY_LIB
413
+ networkPolicyLib: NETWORK_POLICY_LIB,
414
+ allowedModules: this.#options.allowedModules ?? []
360
415
  },
361
416
  env,
362
417
  resourceLimits: this.#options.resourceLimits ?? {
@@ -776,42 +831,42 @@ var CliUiBridge = class {
776
831
  }
777
832
  };
778
833
  const messageOf$4 = (e) => e instanceof Error ? e.message : String(e);
779
- const toPlatform = (p) => p.split("/").join(path.sep);
834
+ const toPlatform$1 = (p) => p.split("/").join(path.sep);
780
835
  const isZipArchive = (data) => data.length > 1 && data[0] === 80 && data[1] === 75;
781
836
  /**
782
- * zip 解包(fflate)。每个条目路径过 resolveInsideRoot:
837
+ * zip 解包(fflate 流式,单条目/总解压体积上限)。每个条目路径过 resolveInsideRoot:
783
838
  * 拒绝 `..` 段、绝对路径、反斜杠穿越(zip-slip 防护)。
784
839
  */
785
- async function extractZipData(fs, data, destRoot) {
786
- let entries;
787
- try {
788
- entries = unzipSync(data);
789
- } catch (e) {
790
- throw new WebSkillError("INSTALL_FAILED", `Failed to read zip archive: ${messageOf$4(e)}`, e);
791
- }
792
- for (const [entryPath, content] of Object.entries(entries)) {
840
+ async function extractZipData(fs, data, destRoot, limits) {
841
+ for (const [entryPath, content] of await unzipWithLimits(data, limits)) {
793
842
  const target = resolveInsideRoot(destRoot, entryPath);
794
843
  if (entryPath.endsWith("/")) await fs.mkdir(target);
795
844
  else await fs.writeBinary(target, content);
796
845
  }
797
846
  }
798
847
  /**
799
- * tar/tar.gz 解包(tar 包)。先列条目逐一过 resolveInsideRoot 校验,
800
- * 再解包;符号链接/硬链接条目不跟随(跳过)。
848
+ * tar/tar.gz 解包(tar 包)。先列条目逐一过 resolveInsideRoot 校验并累计
849
+ * 单条目/总体积上限(声明 size 预检),再解包;符号链接/硬链接条目不跟随(跳过)。
801
850
  */
802
- async function extractTarFile(archivePath, destRoot) {
851
+ async function extractTarFile(archivePath, destRoot, limits) {
852
+ const { maxEntryBytes, maxTotalBytes } = resolveArchiveLimits(limits);
803
853
  try {
804
854
  const entryPaths = [];
855
+ let total = 0;
805
856
  await tar.t({
806
- file: toPlatform(archivePath),
857
+ file: toPlatform$1(archivePath),
807
858
  onentry: (entry) => {
808
859
  entryPaths.push(entry.path);
860
+ const size = typeof entry.size === "number" ? entry.size : 0;
861
+ if (size > maxEntryBytes) throw new WebSkillError("INSTALL_FAILED", `Archive entry exceeds the ${maxEntryBytes}-byte limit: ${entry.path}`);
862
+ total += size;
863
+ if (total > maxTotalBytes) throw new WebSkillError("INSTALL_FAILED", `Archive contents exceed the ${maxTotalBytes}-byte total limit`);
809
864
  }
810
865
  });
811
866
  for (const p of entryPaths) if (!p.replace(/\\/g, "/").split("/").every((s) => s === "" || s === ".")) resolveInsideRoot(destRoot, p);
812
867
  await tar.x({
813
- file: toPlatform(archivePath),
814
- cwd: toPlatform(destRoot),
868
+ file: toPlatform$1(archivePath),
869
+ cwd: toPlatform$1(destRoot),
815
870
  filter: (_p, entry) => !("type" in entry) || entry.type !== "SymbolicLink" && entry.type !== "Link"
816
871
  });
817
872
  } catch (e) {
@@ -830,8 +885,23 @@ async function listFiles(fs, root, prefix = "") {
830
885
  }
831
886
  return out;
832
887
  }
833
- /** 递归拷贝目录(含子目录与二进制文件) */
888
+ const toPlatform = (p) => p.split("/").join(path.sep);
889
+ /** lstat 扫描拒绝符号链接条目(与 tar 解包 filter 策略一致;虚拟 fs 路径不存在于真实 fs 时跳过) */
890
+ async function assertNoSymlinks(platformRoot) {
891
+ let entries;
892
+ try {
893
+ entries = await promises.readdir(platformRoot, { withFileTypes: true });
894
+ } catch {
895
+ return;
896
+ }
897
+ for (const entry of entries) {
898
+ if (entry.isSymbolicLink()) throw new WebSkillError("INSTALL_FAILED", `Refusing to copy symbolic link entry: ${platformRoot}${path.sep}${entry.name}`);
899
+ if (entry.isDirectory()) await assertNoSymlinks(`${platformRoot}${path.sep}${entry.name}`);
900
+ }
901
+ }
902
+ /** 递归拷贝目录(含子目录与二进制文件);真实 fs 上先做符号链接扫描 */
834
903
  async function copyDir(fs, from, to) {
904
+ await assertNoSymlinks(toPlatform(from));
835
905
  await fs.mkdir(to);
836
906
  for (const entry of await fs.list(from)) {
837
907
  const name = entry.path.split("/").pop() ?? "";
@@ -854,8 +924,8 @@ async function exportArchive(fs, skillRoot, options) {
854
924
  for (const rel of await listFiles(fs, skillRoot)) files[rel] = await fs.readBinary(`${skillRoot}/${rel}`);
855
925
  await fs.writeBinary(options.outPath, zipSync(files, { level: 6 }));
856
926
  } else await tar.c({
857
- file: toPlatform(options.outPath),
858
- cwd: toPlatform(skillRoot)
927
+ file: toPlatform$1(options.outPath),
928
+ cwd: toPlatform$1(skillRoot)
859
929
  }, ["."]);
860
930
  return options.outPath;
861
931
  } catch (e) {
@@ -876,7 +946,7 @@ async function readArchiveManifest(fs, archivePath) {
876
946
  let manifestText;
877
947
  try {
878
948
  await tar.t({
879
- file: toPlatform(archivePath),
949
+ file: toPlatform$1(archivePath),
880
950
  onentry: (entry) => {
881
951
  if (entry.path === "webskill.skill-manifest.json" || entry.path.endsWith(`/${"webskill.skill-manifest.json"}`)) {
882
952
  const chunks = [];
@@ -897,28 +967,39 @@ const _execFileP = promisify(execFile);
897
967
  const messageOf$2 = (e) => e instanceof Error ? e.message : String(e);
898
968
  /**
899
969
  * 跨平台执行命令:Windows 下对 npm 等 cmd 包装的命令通过 cmd.exe 代理执行,
900
- * git 等原生 exe 不受影响。
970
+ * git 等原生 exe 不受影响。默认 120s 超时(防挂死安装管线)。
901
971
  */
902
- async function execFileP(command, args) {
972
+ async function execFileP(command, args, options = {}) {
973
+ const timeoutMs = options.timeoutMs ?? 12e4;
903
974
  if (process.platform === "win32" && command === "npm") return _execFileP("cmd", [
904
975
  "/c",
905
976
  command,
906
977
  ...args
907
- ]);
908
- return _execFileP(command, args);
978
+ ], { timeout: timeoutMs });
979
+ return _execFileP(command, args, { timeout: timeoutMs });
909
980
  }
910
981
  /** 命令缺失/失败 → INSTALL_FAILED 结构化诊断 */
911
982
  function commandFailed(command, e) {
912
983
  return new WebSkillError("INSTALL_FAILED", `Failed to run ${command}: ${e?.code === "ENOENT" ? `command "${command}" not found on this system` : messageOf$2(e)}`, e);
913
984
  }
914
- /** git 源:clone --depth 1 + rev-parse HEAD 记录 commit */
985
+ /** git url 协议白名单:https:// git@ SCP 形式;无 scheme 的本地路径放行(dev 工作流);
986
+ * 其余显式协议(ext::/file:// 等)一律拒绝 */
987
+ function assertGitUrlSafe(url) {
988
+ if (url.startsWith("--")) throw new WebSkillError("INSTALL_FAILED", `Git url must not start with "--": ${JSON.stringify(url)}`);
989
+ if (url.startsWith("https://") || url.startsWith("git@")) return;
990
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) throw new WebSkillError("INSTALL_FAILED", `Git url protocol is not allowed (only https://, git@ SCP form, or plain local paths): ${JSON.stringify(url)}`);
991
+ }
992
+ /** git 源:clone --depth 1 + rev-parse HEAD 记录 commit;url/ref 前插 `--` 防选项注入 */
915
993
  async function stageGit(source, ctx) {
994
+ assertGitUrlSafe(source.url);
995
+ if (source.ref?.startsWith("--")) throw new WebSkillError("INSTALL_FAILED", `Git ref must not start with "--": ${JSON.stringify(source.ref)}`);
916
996
  const skillDir = `${ctx.stagingRoot}/repo`;
917
997
  const args = [
918
998
  "clone",
919
999
  "--depth",
920
1000
  "1",
921
1001
  ...source.ref ? ["--branch", source.ref] : [],
1002
+ "--",
922
1003
  source.url,
923
1004
  skillDir
924
1005
  ];
@@ -949,7 +1030,7 @@ function sha256Hex(data) {
949
1030
  return createHash("sha256").update(data).digest("hex");
950
1031
  }
951
1032
  const messageOf$1 = (e) => e instanceof Error ? e.message : String(e);
952
- /** http 源:下载归档(可选 expectedSha256 校验包体)→ 解包 → 定位技能根 */
1033
+ /** http 源:下载归档(Content-Length + 流式累计上限;可选 expectedSha256 校验包体)→ 解包 → 定位技能根/包集 */
953
1034
  async function stageHttp(source, ctx, expectedSha256) {
954
1035
  const fetchImpl = ctx.fetchImpl ?? fetch;
955
1036
  let res;
@@ -959,18 +1040,18 @@ async function stageHttp(source, ctx, expectedSha256) {
959
1040
  throw new WebSkillError("INSTALL_FAILED", `Download failed: ${messageOf$1(e)}`, e);
960
1041
  }
961
1042
  if (!res.ok) throw new WebSkillError("INSTALL_FAILED", `Download failed with HTTP ${res.status}`);
962
- const data = new Uint8Array(await res.arrayBuffer());
1043
+ const data = await readResponseWithLimit(res, ctx.archiveLimits);
963
1044
  if (expectedSha256 !== void 0) {
964
1045
  const actual = sha256Hex(data);
965
1046
  if (actual !== expectedSha256) throw new WebSkillError("INSTALL_FAILED", `Archive checksum mismatch: expected ${expectedSha256}, got ${actual}`);
966
1047
  }
967
1048
  const contentDir = `${ctx.stagingRoot}/content`;
968
1049
  await ctx.fs.mkdir(contentDir);
969
- if (isZipArchive(data)) await extractZipData(ctx.fs, data, contentDir);
1050
+ if (isZipArchive(data)) await extractZipData(ctx.fs, data, contentDir, ctx.archiveLimits);
970
1051
  else {
971
1052
  const archivePath = `${ctx.stagingRoot}/archive.tar`;
972
1053
  await ctx.fs.writeBinary(archivePath, data);
973
- await extractTarFile(toPlatform(archivePath), toPlatform(contentDir));
1054
+ await extractTarFile(toPlatform$1(archivePath), toPlatform$1(contentDir), ctx.archiveLimits);
974
1055
  }
975
1056
  const packFilePath = `${contentDir}/${SKILL_PACK_FILE}`;
976
1057
  if (await ctx.fs.exists(packFilePath)) return {
@@ -1002,16 +1083,27 @@ async function stageLocal(source, ctx) {
1002
1083
  source
1003
1084
  };
1004
1085
  }
1005
- /** npm 源:npm pack 解 tar.gz;技能根取包的 skill/ 子目录(存在时)否则包根 */
1086
+ /** npm 包名(@scope/name name,小写字母数字 . _ - /)与版本(semver 或 dist-tag)正则 */
1087
+ const PACKAGE_NAME_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/;
1088
+ const VERSION_RE = /^[a-z0-9._~^>=<x*+-]+$/i;
1089
+ function assertNpmSpecSafe(source) {
1090
+ const name = source.packageName;
1091
+ const isLocalPath = name.startsWith("/") || name.startsWith("./") || name.startsWith("../");
1092
+ if (name.startsWith("--") || !PACKAGE_NAME_RE.test(name) && !isLocalPath) throw new WebSkillError("INSTALL_FAILED", `Invalid npm package name: ${JSON.stringify(name)}`);
1093
+ if (source.version !== void 0 && (!VERSION_RE.test(source.version) || source.version.startsWith("--"))) throw new WebSkillError("INSTALL_FAILED", `Invalid npm package version: ${JSON.stringify(source.version)}`);
1094
+ }
1095
+ /** npm 源:npm pack → 解 tar.gz;技能根取包的 skill/ 子目录(存在时)否则包根;spec 前插 `--` 防选项注入 */
1006
1096
  async function stageNpm(source, ctx) {
1097
+ assertNpmSpecSafe(source);
1007
1098
  const spec = source.version ? `${source.packageName}@${source.version}` : source.packageName;
1008
1099
  let stdout;
1009
1100
  try {
1010
1101
  ({stdout} = await execFileP("npm", [
1011
1102
  "pack",
1012
- spec,
1013
1103
  "--pack-destination",
1014
- ctx.stagingRoot
1104
+ ctx.stagingRoot,
1105
+ "--",
1106
+ spec
1015
1107
  ]));
1016
1108
  } catch (e) {
1017
1109
  throw commandFailed("npm", e);
@@ -1021,7 +1113,7 @@ async function stageNpm(source, ctx) {
1021
1113
  if (!tarball || !await ctx.fs.exists(tgzPath)) throw new WebSkillError("INSTALL_FAILED", `npm pack output not found in staging directory: ${tarball ?? "(empty output)"}`);
1022
1114
  const pkgDir = `${ctx.stagingRoot}/pkg`;
1023
1115
  await ctx.fs.mkdir(pkgDir);
1024
- await extractTarFile(toPlatform(tgzPath), toPlatform(pkgDir));
1116
+ await extractTarFile(toPlatform$1(tgzPath), toPlatform$1(pkgDir));
1025
1117
  const pkgRoot = `${pkgDir}/package`;
1026
1118
  const skillDir = await ctx.fs.exists(`${pkgRoot}/skill/SKILL.md`) ? `${pkgRoot}/skill` : pkgRoot;
1027
1119
  let packageName = source.packageName;
@@ -1112,15 +1204,13 @@ async function readManifest(fs, skillRoot) {
1112
1204
  throw new WebSkillError("INTEGRITY_FAILED", `Skill manifest at ${path} is corrupted: ${e instanceof Error ? e.message : String(e)}`, e);
1113
1205
  }
1114
1206
  }
1115
- /** manifest.files 逐文件重算 sha256 比对(core 纯逻辑校验) */
1207
+ /** walk 实际目录与 manifest 比对:hash 不一致 / 清单外新增 / 清单内缺失三类全空才 ok */
1116
1208
  async function verifyIntegrity(fs, skillRoot) {
1117
1209
  const manifest = await readManifest(fs, skillRoot);
1118
1210
  const actualHashes = /* @__PURE__ */ new Map();
1119
- for (const file of manifest.files) {
1120
- const path = `${skillRoot}/${file.path}`;
1121
- if (await fs.exists(path)) actualHashes.set(file.path, sha256Hex(await fs.readBinary(path)));
1122
- }
1123
- return verifyManifest(manifest, actualHashes);
1211
+ const actualFiles = await walkFiles(fs, skillRoot);
1212
+ for (const rel of actualFiles) actualHashes.set(rel, sha256Hex(await fs.readBinary(`${skillRoot}/${rel}`)));
1213
+ return verifyManifest(manifest, actualHashes, actualFiles);
1124
1214
  }
1125
1215
  const messageOf = (e) => e instanceof Error ? e.message : String(e);
1126
1216
  const asInstallFailed = (e) => e instanceof WebSkillError && e.code === "INSTALL_FAILED" ? e : new WebSkillError("INSTALL_FAILED", `Install failed: ${messageOf(e)}`, e);
@@ -1134,12 +1224,16 @@ var SkillManager = class {
1134
1224
  #fs;
1135
1225
  #fetchImpl;
1136
1226
  #schemaInference;
1227
+ #archiveLimits;
1228
+ #onChanged;
1137
1229
  #schemaInferer = new OxcSchemaInferer();
1138
1230
  constructor(deps) {
1139
1231
  this.#managedRoot = deps.managedRoot.replace(/\/+$/, "");
1140
1232
  this.#fs = deps.fs ?? new NodeFS();
1141
1233
  this.#fetchImpl = deps.fetchImpl;
1142
1234
  this.#schemaInference = deps.schemaInference ?? true;
1235
+ this.#archiveLimits = deps.archiveLimits;
1236
+ this.#onChanged = deps.onChanged;
1143
1237
  }
1144
1238
  async install(source, options) {
1145
1239
  const fs = this.#fs;
@@ -1150,7 +1244,8 @@ var SkillManager = class {
1150
1244
  const ctx = {
1151
1245
  fs,
1152
1246
  stagingRoot,
1153
- ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
1247
+ ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {},
1248
+ ...this.#archiveLimits ? { archiveLimits: this.#archiveLimits } : {}
1154
1249
  };
1155
1250
  let staged;
1156
1251
  switch (source.type) {
@@ -1187,21 +1282,34 @@ var SkillManager = class {
1187
1282
  const errors = report.issues.filter((i) => i.severity === "error");
1188
1283
  throw new WebSkillError("INSTALL_FAILED", `Skill "${name}" failed validation: ${errors.map((i) => i.message).join("; ")}`, errors);
1189
1284
  }
1190
- targetDir = `${this.#managedRoot}/${name}`;
1191
- if (await fs.exists(targetDir)) await fs.remove(targetDir, { recursive: true });
1192
- await copyDir(fs, finalDir, targetDir);
1193
- await this.#inferSkillSchemas(targetDir);
1194
- const manifest = await createManifest(fs, targetDir, {
1285
+ await this.#inferSkillSchemas(finalDir);
1286
+ const manifest = await createManifest(fs, finalDir, {
1195
1287
  name,
1196
1288
  ...version ? { version } : {},
1197
1289
  source: staged.source
1198
1290
  });
1199
- await upsertLockEntry(fs, this.#managedRoot, name, {
1200
- digest: manifest.integrity.digest,
1201
- installedAt: manifest.installedAt,
1202
- source: staged.source
1203
- });
1291
+ targetDir = `${this.#managedRoot}/${name}`;
1292
+ const backupDir = `${stagingRoot}/backup`;
1293
+ const hadPrevious = await fs.exists(targetDir);
1294
+ if (hadPrevious) await copyDir(fs, targetDir, backupDir);
1295
+ let committed = false;
1296
+ try {
1297
+ if (hadPrevious) await fs.remove(targetDir, { recursive: true });
1298
+ await copyDir(fs, finalDir, targetDir);
1299
+ committed = true;
1300
+ await upsertLockEntry(fs, this.#managedRoot, name, {
1301
+ digest: manifest.integrity.digest,
1302
+ installedAt: manifest.installedAt,
1303
+ source: staged.source
1304
+ });
1305
+ } catch (e) {
1306
+ if (committed) await removeDirQuiet(fs, targetDir);
1307
+ if (hadPrevious) await copyDir(fs, backupDir, targetDir);
1308
+ targetDir = void 0;
1309
+ throw e;
1310
+ }
1204
1311
  targetDir = void 0;
1312
+ this.#onChanged?.();
1205
1313
  return manifest;
1206
1314
  } catch (e) {
1207
1315
  if (targetDir) await removeDirQuiet(fs, targetDir);
@@ -1243,11 +1351,7 @@ var SkillManager = class {
1243
1351
  }
1244
1352
  const manifests = [];
1245
1353
  for (const entry of pack.manifest.skills) {
1246
- const targetDir = `${this.#managedRoot}/${entry.name}`;
1247
- if (await fs.exists(targetDir)) await fs.remove(targetDir, { recursive: true });
1248
- await copyDir(fs, `${finalRoot}/${entry.name}`, targetDir);
1249
- installed.push(targetDir);
1250
- const manifest = await createManifest(fs, targetDir, {
1354
+ const manifest = await createManifest(fs, `${finalRoot}/${entry.name}`, {
1251
1355
  name: entry.name,
1252
1356
  ...versions.get(entry.name) ? { version: versions.get(entry.name) } : {},
1253
1357
  source
@@ -1255,11 +1359,36 @@ var SkillManager = class {
1255
1359
  if (manifest.integrity.digest !== entry.digest) throw new WebSkillError("INSTALL_FAILED", `Skill "${entry.name}" digest mismatch: expected ${entry.digest}, got ${manifest.integrity.digest}`);
1256
1360
  manifests.push(manifest);
1257
1361
  }
1258
- for (const manifest of manifests) await upsertLockEntry(fs, this.#managedRoot, manifest.name, {
1259
- digest: manifest.integrity.digest,
1260
- installedAt: manifest.installedAt,
1261
- source
1262
- });
1362
+ const swapped = [];
1363
+ try {
1364
+ for (const manifest of manifests) {
1365
+ const targetDir = `${this.#managedRoot}/${manifest.name}`;
1366
+ const backupDir = `${stagingRoot}/backup/${manifest.name}`;
1367
+ if (await fs.exists(targetDir)) {
1368
+ await copyDir(fs, targetDir, backupDir);
1369
+ await fs.remove(targetDir, { recursive: true });
1370
+ }
1371
+ await copyDir(fs, `${finalRoot}/${manifest.name}`, targetDir);
1372
+ installed.push(targetDir);
1373
+ swapped.push({
1374
+ targetDir,
1375
+ backupDir
1376
+ });
1377
+ }
1378
+ for (const manifest of manifests) await upsertLockEntry(fs, this.#managedRoot, manifest.name, {
1379
+ digest: manifest.integrity.digest,
1380
+ installedAt: manifest.installedAt,
1381
+ source
1382
+ });
1383
+ } catch (e) {
1384
+ for (const { targetDir, backupDir } of swapped) {
1385
+ await removeDirQuiet(fs, targetDir);
1386
+ if (await fs.exists(backupDir)) await copyDir(fs, backupDir, targetDir);
1387
+ }
1388
+ installed.length = 0;
1389
+ throw e;
1390
+ }
1391
+ this.#onChanged?.();
1263
1392
  return manifests;
1264
1393
  } catch (e) {
1265
1394
  for (const targetDir of installed) await removeDirQuiet(fs, targetDir);
@@ -1295,6 +1424,7 @@ var SkillManager = class {
1295
1424
  try {
1296
1425
  await this.#fs.remove(targetDir, { recursive: true });
1297
1426
  await removeLockEntry(this.#fs, this.#managedRoot, name);
1427
+ this.#onChanged?.();
1298
1428
  } catch (e) {
1299
1429
  throw new WebSkillError("UNINSTALL_FAILED", `Failed to uninstall "${name}": ${messageOf(e)}`, e);
1300
1430
  }