@webskill/sdk 0.2.3 → 0.2.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.
@@ -16,6 +16,10 @@ var WebSkillError = class extends Error {
16
16
  this.details = details;
17
17
  }
18
18
  };
19
+ /** unknown 异常取消息文本(全仓单一来源,勿再本地重复定义) */
20
+ function messageOf(e) {
21
+ return e instanceof Error ? e.message : String(e);
22
+ }
19
23
  /** 技能目录内的安装清单文件名(不参与 files 列表与 digest) */
20
24
  const SKILL_MANIFEST_FILE = "webskill.skill-manifest.json";
21
25
  /** managed root 下的锁定文件名(不参与 files 列表与 digest) */
@@ -111,6 +115,18 @@ var MemoryFS = class {
111
115
  async writeText(path, content) {
112
116
  await this.writeBinary(path, new TextEncoder().encode(content));
113
117
  }
118
+ async appendText(path, content) {
119
+ const key = this.#normalize(path);
120
+ const existing = this.#entries.get(key);
121
+ if (existing && existing.type !== "file") throw new WebSkillError("FS_PERMISSION_DENIED", `Cannot append to a directory: ${key}`);
122
+ const prior = existing ? new TextDecoder().decode(existing.content) : "";
123
+ this.#ensureParents(key);
124
+ this.#entries.set(key, {
125
+ type: "file",
126
+ content: new TextEncoder().encode(prior + content),
127
+ mtimeMs: Date.now()
128
+ });
129
+ }
114
130
  async readBinary(path) {
115
131
  return this.#getFile(this.#normalize(path)).content;
116
132
  }
@@ -188,7 +204,12 @@ var MemoryFS = class {
188
204
  this.#entries.delete(src);
189
205
  }
190
206
  };
191
- /** 原子写文本:先写临时文件再 rename(进程崩溃不留下半写文件;lockfile 等账本场景) */
207
+ /**
208
+ * 原子写文本:先写临时文件再 rename(进程崩溃不留下半写文件;lockfile 等账本场景)。
209
+ * 原子性强弱取决于 provider 的 rename 实现:NodeFS/MemoryFS 为真 rename;
210
+ * OPFS 无原生 move,rename 是 copy+delete(非原子,崩溃可能残留双份),
211
+ * 对崩溃一致性要求极高的场景在浏览器侧需知悉此前提。
212
+ */
192
213
  async function atomicWriteText(fs, path, content) {
193
214
  const tmp = `${path}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
194
215
  await fs.writeText(tmp, content);
@@ -203,7 +224,9 @@ function normalizePath(path) {
203
224
  * 拒绝空串、`.`、`..`、含 `/` 或 `\`、含 `:`(Windows 盘符/ADS)。
204
225
  * 违规抛 FS_PATH_OUTSIDE_ROOT(kind 用于错误消息定位,如 "runId")。
205
226
  */
227
+ const CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/;
206
228
  function assertSafePathSegment(segment, kind) {
229
+ if (CONTROL_CHARS_RE.test(segment)) throw new WebSkillError("FS_PATH_OUTSIDE_ROOT", `Invalid ${kind} (control characters including NUL are not allowed): ${JSON.stringify(segment)}`);
207
230
  if (segment === "" || segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\") || segment.includes(":")) throw new WebSkillError("FS_PATH_OUTSIDE_ROOT", `Invalid ${kind} (must be a single safe path segment): ${JSON.stringify(segment)}`);
208
231
  }
209
232
  /**
@@ -221,6 +244,7 @@ function resolveInsideRoot(root, relativePath) {
221
244
  if (relativePath.trim() === "") fail("empty path");
222
245
  if (relativePath.startsWith("/") || relativePath.startsWith("\\") || /^[a-zA-Z]:[\\/]/.test(relativePath)) fail("absolute paths not allowed");
223
246
  const segments = relativePath.replace(/\\/g, "/").split("/");
247
+ if (CONTROL_CHARS_RE.test(relativePath)) fail("control characters including NUL are not allowed");
224
248
  if (segments.some((s) => s === "..")) fail("`..` segments not allowed");
225
249
  const clean = segments.filter((s) => s !== "" && s !== ".");
226
250
  if (clean.length === 0) fail("path resolves to empty");
@@ -228,6 +252,65 @@ function resolveInsideRoot(root, relativePath) {
228
252
  const joined = normalizedRoot === "" ? clean.join("/") : `${normalizedRoot}/${clean.join("/")}`;
229
253
  return root.startsWith("/") || root.startsWith("\\") ? `/${joined}` : joined;
230
254
  }
255
+ const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost."]);
256
+ function ipv4Octets(host) {
257
+ const parts = host.split(".");
258
+ if (parts.length !== 4) return void 0;
259
+ const octets = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
260
+ return octets.every((n) => n >= 0 && n <= 255) ? octets : void 0;
261
+ }
262
+ function isPrivateIpv4(octets) {
263
+ const [a, b] = octets;
264
+ return a === 0 || a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
265
+ }
266
+ function isPrivateIpv6(host) {
267
+ const h = host.toLowerCase();
268
+ if (h === "::1" || h === "::") return true;
269
+ const mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
270
+ if (mapped) {
271
+ const octets = ipv4Octets(mapped[1]);
272
+ return octets !== void 0 && isPrivateIpv4(octets);
273
+ }
274
+ const mappedHex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
275
+ if (mappedHex) {
276
+ const hi = parseInt(mappedHex[1], 16);
277
+ const lo = parseInt(mappedHex[2], 16);
278
+ return isPrivateIpv4([
279
+ hi >> 8,
280
+ hi & 255,
281
+ lo >> 8,
282
+ lo & 255
283
+ ]);
284
+ }
285
+ const first = h.split(":")[0];
286
+ if (/^[0-9a-f]{1,4}$/.test(first)) {
287
+ const n = parseInt(first, 16);
288
+ if ((n & 65472) === 65152) return true;
289
+ if ((n & 65024) === 64512) return true;
290
+ }
291
+ return false;
292
+ }
293
+ function isPrivateHost(hostname) {
294
+ const host = hostname.toLowerCase();
295
+ if (LOOPBACK_HOSTNAMES.has(host) || host.endsWith(".localhost")) return true;
296
+ const v4 = ipv4Octets(host);
297
+ if (v4) return isPrivateIpv4(v4);
298
+ if (host.startsWith("[") && host.endsWith("]")) return isPrivateIpv6(host.slice(1, -1));
299
+ if (host.includes(":")) return isPrivateIpv6(host);
300
+ return false;
301
+ }
302
+ /**
303
+ * 校验远程 URL 是否允许连接;违规抛 NETWORK_BLOCKED。
304
+ * rawUrl 非合法 URL 时抛 TypeError(由调用方按自身错误码包装)。
305
+ */
306
+ function assertRemoteUrlAllowed(rawUrl, policy = {}) {
307
+ const url = new URL(rawUrl);
308
+ if (url.protocol === "http:") {
309
+ if (!policy.allowHttp) throw new WebSkillError("NETWORK_BLOCKED", `Insecure URL scheme "http:" is not allowed (opt in via allowHttp): ${url.href}`);
310
+ } else if (url.protocol !== "https:") throw new WebSkillError("NETWORK_BLOCKED", `URL scheme "${url.protocol}" is not allowed (https only): ${url.href}`);
311
+ if (!policy.allowPrivateHosts && isPrivateHost(url.hostname)) throw new WebSkillError("NETWORK_BLOCKED", `Refusing to connect to a private/loopback/link-local address: ${url.hostname}`);
312
+ return url;
313
+ }
231
314
  const FRONTMATTER_RE = /^---[^\S\r\n]*\r?\n([\s\S]*?)\r?\n---[^\S\r\n]*(?:\r?\n|$)([\s\S]*)$/;
232
315
  const invalid = (message, details) => {
233
316
  throw new WebSkillError("SKILL_INVALID_METADATA", message, details);
@@ -368,7 +451,6 @@ function checkDependencyCycles(adjacency) {
368
451
  */
369
452
  /** 包级清单文件名(位于 zip 根,不参与各技能 digest) */
370
453
  const SKILL_PACK_FILE = "webskill.skill-pack.json";
371
- const messageOf = (e) => e instanceof Error ? e.message : String(e);
372
454
  const baseName$1 = (p) => p.split("/").pop() ?? p;
373
455
  async function listFilesRecursive(fs, root, prefix = "") {
374
456
  const out = [];
@@ -392,6 +474,7 @@ async function exportSkills(fs, input) {
392
474
  for (const root of input.roots) {
393
475
  const base = root.replace(/\/+$/, "");
394
476
  const manifest = await input.manifestBuilder(base);
477
+ if (!isValidSkillName(manifest.name)) throw new WebSkillError("EXPORT_FAILED", `Skill "${manifest.name}" has an invalid name and cannot be exported to a skill pack`);
395
478
  skills.push({
396
479
  name: manifest.name,
397
480
  digest: manifest.integrity.digest
@@ -427,7 +510,7 @@ function parseSkillPackManifest(text) {
427
510
  for (const entry of record["skills"]) {
428
511
  if (typeof entry !== "object" || entry === null) invalid("skill entry is not an object");
429
512
  const { name, digest } = entry;
430
- if (typeof name !== "string" || name === "") invalid("skill entry missing a valid \"name\"");
513
+ if (typeof name !== "string" || name === "" || !isValidSkillName(name)) invalid(`skill entry has an invalid name: ${JSON.stringify(name)}`);
431
514
  if (typeof digest !== "string" || digest === "") invalid(`skill "${name}" missing a valid "digest"`);
432
515
  }
433
516
  return raw;
@@ -468,6 +551,14 @@ async function unzipWithLimits(data, limits) {
468
551
  const unzip = new Unzip();
469
552
  unzip.register(UnzipInflate);
470
553
  unzip.onfile = (file) => {
554
+ if (file.name.startsWith("/") || file.name.startsWith("\\") || /^[a-zA-Z]:[\\/]/.test(file.name)) {
555
+ failure = new WebSkillError("INSTALL_FAILED", `Archive entry escapes the destination root: ${file.name}`);
556
+ return;
557
+ }
558
+ if (file.name.replace(/\\/g, "/").split("/").some((s) => s === "..") || /[\x00-\x1f\x7f]/.test(file.name)) {
559
+ failure = new WebSkillError("INSTALL_FAILED", `Archive entry escapes the destination root: ${file.name}`);
560
+ return;
561
+ }
471
562
  if (file.name.endsWith("/")) {
472
563
  entries.push([file.name, /* @__PURE__ */ new Uint8Array(0)]);
473
564
  return;
@@ -579,7 +670,7 @@ var SkillDiscovery = class {
579
670
  const candidates = [];
580
671
  const knownSkillNames = /* @__PURE__ */ new Set();
581
672
  const adjacency = /* @__PURE__ */ new Map();
582
- for (const root of this.#roots) {
673
+ for (const [rootIndex, root] of this.#roots.entries()) {
583
674
  if (!await this.#fs.exists(root)) {
584
675
  issues.push({
585
676
  code: "FS_NOT_FOUND",
@@ -604,6 +695,7 @@ var SkillDiscovery = class {
604
695
  candidates.push({
605
696
  dirName,
606
697
  skillRoot,
698
+ rootIndex,
607
699
  hasSkillMd,
608
700
  metadata,
609
701
  parseError,
@@ -616,6 +708,7 @@ var SkillDiscovery = class {
616
708
  }
617
709
  }));
618
710
  }
711
+ candidates.sort((a, b) => a.rootIndex - b.rootIndex || a.dirName.localeCompare(b.dirName));
619
712
  const cycles = checkDependencyCycles(adjacency);
620
713
  const claimedNames = /* @__PURE__ */ new Set();
621
714
  for (const candidate of candidates) {
@@ -743,4 +836,4 @@ const xmlRenderer = {
743
836
  };
744
837
 
745
838
  //#endregion
746
- export { unzipWithLimits as A, parseSkillMarkdown as C, renderCatalogJson as D, renderAvailableSkillsXml as E, verifyManifest as M, xmlRenderer as N, resolveArchiveLimits as O, normalizePath as S, readResponseWithLimit as T, computeDigest as _, SKILL_NAME_MAX_LENGTH as a, isValidSkillName as b, SkillDiscovery as c, assertSafePathSegment as d, atomicWriteText as f, checkSkillRules as g, checkDependencyCycles as h, SKILL_MANIFEST_FILE as i, validateSkills as j, resolveInsideRoot as k, SkillReader as l, buildManifest as m, MemoryFS as n, SKILL_NAME_PATTERN as o, buildCatalog as p, SKILLS_LOCKFILE as r, SKILL_PACK_FILE as s, DEFAULT_ARCHIVE_LIMITS as t, WebSkillError as u, escapeXml as v, parseSkillPackManifest as w, jsonRenderer as x, exportSkills as y };
839
+ export { resolveArchiveLimits as A, messageOf as C, readResponseWithLimit as D, parseSkillPackManifest as E, xmlRenderer as F, unzipWithLimits as M, validateSkills as N, renderAvailableSkillsXml as O, verifyManifest as P, jsonRenderer as S, parseSkillMarkdown as T, checkSkillRules as _, SKILL_NAME_MAX_LENGTH as a, exportSkills as b, SkillDiscovery as c, assertRemoteUrlAllowed as d, assertSafePathSegment as f, checkDependencyCycles as g, buildManifest as h, SKILL_MANIFEST_FILE as i, resolveInsideRoot as j, renderCatalogJson as k, SkillReader as l, buildCatalog as m, MemoryFS as n, SKILL_NAME_PATTERN as o, atomicWriteText as p, SKILLS_LOCKFILE as r, SKILL_PACK_FILE as s, DEFAULT_ARCHIVE_LIMITS as t, WebSkillError as u, computeDigest as v, normalizePath as w, isValidSkillName as x, escapeXml as y };
@@ -1,5 +1,5 @@
1
- import { A as unzipWithLimits, C as parseSkillMarkdown, M as verifyManifest, O as resolveArchiveLimits, T as readResponseWithLimit, b as isValidSkillName, f as atomicWriteText, i as SKILL_MANIFEST_FILE, j as validateSkills, k as resolveInsideRoot, m as buildManifest, r as SKILLS_LOCKFILE, s as SKILL_PACK_FILE, u as WebSkillError, w as parseSkillPackManifest, y as exportSkills } from "./dist-D0saNPi_.js";
2
- import { A as isNetworkAllowed, C as bridgeError, M as networkUrlHost, N as normalizeToolContent, P as parseBridgeRequest, c as FsArtifactStore, l as FsMemoryStore, o as CapabilityApproval } from "./dist-NM4Mylx4.js";
1
+ import { A as resolveArchiveLimits, C as messageOf, D as readResponseWithLimit, E as parseSkillPackManifest, M as unzipWithLimits, N as validateSkills, P as verifyManifest, T as parseSkillMarkdown, b as exportSkills, d as assertRemoteUrlAllowed, h as buildManifest, i as SKILL_MANIFEST_FILE, j as resolveInsideRoot, p as atomicWriteText, r as SKILLS_LOCKFILE, s as SKILL_PACK_FILE, u as WebSkillError, x as isValidSkillName } from "./dist-BQzncxXg.js";
2
+ import { A as isNetworkAllowed, C as bridgeError, F as normalizeToolError, I as parseBridgeRequest, M as networkUrlHost, P as normalizeToolContent, c as FsArtifactStore, l as FsMemoryStore, o as CapabilityApproval } from "./dist-B77plHjw.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { unzipSync, zipSync } from "fflate";
5
5
  import { existsSync, promises, realpathSync } from "node:fs";
@@ -37,13 +37,16 @@ async function realpathNearest(platformPath) {
37
37
  * 可选 root 模式:构造传入 root 后,read/write 操作先做 realpath 包含校验
38
38
  * (root 与目标都 realpath 后前缀比对),经符号链接逃逸 root → FS_PATH_OUTSIDE_ROOT。
39
39
  */
40
- var NodeFS = class {
40
+ var NodeFS = class NodeFS {
41
41
  kind = "node";
42
42
  #root;
43
43
  constructor(deps = {}) {
44
44
  this.#root = deps.root;
45
45
  }
46
- /** root 模式:目标 realpath 必须落在 root realpath 前缀内(符号链接逃逸防护) */
46
+ /** root 模式:全部方法(read/write/exists/stat/list/mkdir/remove/rename)目标 realpath 必须落在 root realpath 前缀内 */
47
+ withRoot(root) {
48
+ return new NodeFS({ root });
49
+ }
47
50
  async #assertContained(p) {
48
51
  if (!this.#root) return;
49
52
  const rootReal = await promises.realpath(toPlatform$4(this.#root));
@@ -64,6 +67,12 @@ var NodeFS = class {
64
67
  await promises.mkdir(path.dirname(target), { recursive: true });
65
68
  await promises.writeFile(target, content, "utf8");
66
69
  }
70
+ async appendText(p, content) {
71
+ await this.#assertContained(p);
72
+ const target = toPlatform$4(p);
73
+ await promises.mkdir(path.dirname(target), { recursive: true });
74
+ await promises.appendFile(target, content, "utf8");
75
+ }
67
76
  async readBinary(p) {
68
77
  await this.#assertContained(p);
69
78
  try {
@@ -79,6 +88,7 @@ var NodeFS = class {
79
88
  await promises.writeFile(target, content);
80
89
  }
81
90
  async exists(p) {
91
+ await this.#assertContained(p);
82
92
  try {
83
93
  await promises.access(toPlatform$4(p));
84
94
  return true;
@@ -87,6 +97,7 @@ var NodeFS = class {
87
97
  }
88
98
  }
89
99
  async stat(p) {
100
+ await this.#assertContained(p);
90
101
  try {
91
102
  const s = await promises.stat(toPlatform$4(p));
92
103
  return {
@@ -100,6 +111,7 @@ var NodeFS = class {
100
111
  }
101
112
  }
102
113
  async list(p) {
114
+ await this.#assertContained(p);
103
115
  let dirents;
104
116
  try {
105
117
  dirents = await promises.readdir(toPlatform$4(p), { withFileTypes: true });
@@ -112,9 +124,11 @@ var NodeFS = class {
112
124
  }));
113
125
  }
114
126
  async mkdir(p) {
127
+ await this.#assertContained(p);
115
128
  await promises.mkdir(toPlatform$4(p), { recursive: true });
116
129
  }
117
130
  async remove(p, options) {
131
+ await this.#assertContained(p);
118
132
  try {
119
133
  await promises.rm(toPlatform$4(p), { recursive: options?.recursive ?? false });
120
134
  } catch (e) {
@@ -122,6 +136,8 @@ var NodeFS = class {
122
136
  }
123
137
  }
124
138
  async rename(from, to) {
139
+ await this.#assertContained(from);
140
+ await this.#assertContained(to);
125
141
  await promises.mkdir(path.dirname(toPlatform$4(to)), { recursive: true });
126
142
  try {
127
143
  await promises.rename(toPlatform$4(from), toPlatform$4(to));
@@ -279,7 +295,6 @@ var NodeScriptExecutor = class {
279
295
  };
280
296
  const baseName$1 = (p) => p.split("/").pop() ?? p;
281
297
  const toPlatform$3 = (p) => p.split("/").join(path.sep);
282
- const messageOf$6 = (e) => e instanceof Error ? e.message : String(e);
283
298
  /**
284
299
  * 网络策略判定函数源码(注入 Worker;匹配逻辑单一来源在
285
300
  * runtime/sandbox/networkPolicy.ts,Worker 线程不走 vitest 别名故注入而非 import)
@@ -365,7 +380,7 @@ var SandboxedScriptExecutor = class {
365
380
  content: [],
366
381
  error: {
367
382
  code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
368
- message: messageOf$6(e)
383
+ message: messageOf(e)
369
384
  }
370
385
  };
371
386
  }
@@ -381,12 +396,13 @@ var SandboxedScriptExecutor = class {
381
396
  }, timeoutMs, (request) => this.#handleBridge(request, context), (host) => context.onWarning?.(`Network request blocked by sandbox network policy: ${host}`));
382
397
  if (!result.ok) {
383
398
  const stderrSummary = result.stderr?.length ? ` | stderr: ${result.stderr.join(" | ").slice(0, 500)}` : "";
399
+ const normalized = normalizeToolError(result.error?.code, result.error?.message ?? "script execution failed");
384
400
  return {
385
401
  ok: false,
386
402
  content: [],
387
403
  error: {
388
- code: result.error?.code ?? "TOOL_EXECUTION_FAILED",
389
- message: `${result.error?.message ?? "script execution failed"}${stderrSummary}`
404
+ code: normalized.code,
405
+ message: `${normalized.message}${stderrSummary}`
390
406
  }
391
407
  };
392
408
  }
@@ -405,7 +421,7 @@ var SandboxedScriptExecutor = class {
405
421
  content: [],
406
422
  error: {
407
423
  code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
408
- message: messageOf$6(e)
424
+ message: messageOf(e)
409
425
  }
410
426
  };
411
427
  }
@@ -456,14 +472,14 @@ var SandboxedScriptExecutor = class {
456
472
  respond(bridgeError("unknown", "TOOL_EXECUTION_FAILED", "invalid bridge request"));
457
473
  return;
458
474
  }
459
- onBridge(request).then(respond, (e) => respond(bridgeError(request.id, "TOOL_EXECUTION_FAILED", messageOf$6(e))));
475
+ onBridge(request).then(respond, (e) => respond(bridgeError(request.id, "TOOL_EXECUTION_FAILED", messageOf(e))));
460
476
  return;
461
477
  }
462
478
  if (msg?.type === "load-result" || msg?.type === "execute-result") done(() => resolve(msg));
463
479
  });
464
480
  worker.on("error", (e) => done(() => reject(e)));
465
481
  worker.on("exit", (code) => {
466
- if (code !== 0) done(() => reject(new WebSkillError("TOOL_EXECUTION_FAILED", `Sandbox worker exited with code ${code}`)));
482
+ if (!settled) done(() => reject(new WebSkillError("TOOL_EXECUTION_FAILED", `Sandbox worker exited ${code === null ? "by signal" : `with code ${code}`} without producing a result`)));
467
483
  });
468
484
  });
469
485
  }
@@ -517,13 +533,12 @@ var SandboxedScriptExecutor = class {
517
533
  }
518
534
  }
519
535
  } catch (e) {
520
- return bridgeError(request.id, e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", messageOf$6(e));
536
+ return bridgeError(request.id, e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", messageOf(e));
521
537
  }
522
538
  }
523
539
  };
524
540
  const baseName = (p) => p.split("/").pop() ?? p;
525
541
  const toPlatform$2 = (p) => p.split("/").join(path.sep);
526
- const messageOf$5 = (e) => e instanceof Error ? e.message : String(e);
527
542
  const NETWORK_POLICY_LIB = `${isNetworkAllowed.toString()}\n${networkUrlHost.toString()}`;
528
543
  const readAllow = (p) => [`--allow-fs-read=${p}`, `--allow-fs-read=${realpathSync(p)}`];
529
544
  const writeAllow = (p) => [`--allow-fs-write=${p}`, `--allow-fs-write=${realpathSync(p)}`];
@@ -555,6 +570,7 @@ var ProcessSandboxExecutor = class {
555
570
  #approval;
556
571
  #slots = [];
557
572
  #waiters = [];
573
+ #disposed = false;
558
574
  constructor(fs, options = {}) {
559
575
  this.#fs = fs;
560
576
  this.#options = options;
@@ -572,8 +588,9 @@ var ProcessSandboxExecutor = class {
572
588
  get poolSize() {
573
589
  return this.#options.poolSize ?? 2;
574
590
  }
575
- /** 池全部子进程销毁(测试收尾/进程退出前调用) */
591
+ /** 池全部子进程销毁(测试收尾/进程退出前调用);排队中的 acquire 一律 reject(不悬挂) */
576
592
  async dispose() {
593
+ this.#disposed = true;
577
594
  for (const slot of this.#slots) {
578
595
  slot.child.kill();
579
596
  await rm(slot.artifactDir, {
@@ -582,7 +599,8 @@ var ProcessSandboxExecutor = class {
582
599
  }).catch(() => void 0);
583
600
  }
584
601
  this.#slots = [];
585
- this.#waiters = [];
602
+ const waiters = this.#waiters.splice(0);
603
+ for (const waiter of waiters) waiter.reject(new WebSkillError("TOOL_EXECUTION_FAILED", "Process sandbox executor disposed while a caller was waiting for a pool slot"));
586
604
  }
587
605
  async #locateScript(skillRoot, scriptName) {
588
606
  const tsPath = `${skillRoot}/scripts/${scriptName}.ts`;
@@ -595,6 +613,7 @@ var ProcessSandboxExecutor = class {
595
613
  /** 取一个可用子进程(同 key 复用 / 淘汰 idle 重生 / 扩容 / 排队) */
596
614
  async #acquire(key) {
597
615
  for (;;) {
616
+ if (this.#disposed) throw new WebSkillError("TOOL_EXECUTION_FAILED", "Process sandbox executor is disposed");
598
617
  const idle = this.#slots.filter((s) => !s.busy);
599
618
  const match = idle.find((s) => s.key === key);
600
619
  if (match) {
@@ -616,7 +635,10 @@ var ProcessSandboxExecutor = class {
616
635
  this.#slots.push(slot);
617
636
  return slot;
618
637
  }
619
- await new Promise((resolve) => this.#waiters.push(resolve));
638
+ await new Promise((resolve, reject) => this.#waiters.push({
639
+ resolve,
640
+ reject
641
+ }));
620
642
  }
621
643
  }
622
644
  /** 执行后回收:kill 旧子进程,补位重生同 key 新子进程(温池保持),唤醒排队 */
@@ -628,13 +650,27 @@ var ProcessSandboxExecutor = class {
628
650
  recursive: true,
629
651
  force: true
630
652
  }).catch(() => void 0);
631
- this.#spawnSlot(slot.key).then((fresh) => {
653
+ this.#spawnSlot(slot.key).then(async (fresh) => {
654
+ if (this.#disposed) {
655
+ fresh.child.kill();
656
+ await rm(fresh.artifactDir, {
657
+ recursive: true,
658
+ force: true
659
+ }).catch(() => void 0);
660
+ return;
661
+ }
632
662
  this.#slots.push(fresh);
633
- }).catch(() => void 0).finally(() => {
663
+ }).catch((e) => {
664
+ this.#warn(`Failed to respawn sandbox child for pool maintenance: ${messageOf(e)}`);
665
+ }).finally(() => {
634
666
  const waiter = this.#waiters.shift();
635
- if (waiter) waiter();
667
+ if (waiter) waiter.resolve();
636
668
  });
637
669
  }
670
+ #warn(message) {
671
+ if (this.#options.onWarning) this.#options.onWarning(message);
672
+ else console.warn(message);
673
+ }
638
674
  async #spawnSlot(key) {
639
675
  const artifactDir = await mkdtemp(path.join(tmpdir(), "webskill-psbx-out-")).then((d) => d.split(path.sep).join("/"));
640
676
  const entry = processEntryPath();
@@ -695,7 +731,7 @@ var ProcessSandboxExecutor = class {
695
731
  content: [],
696
732
  error: {
697
733
  code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
698
- message: messageOf$5(e)
734
+ message: messageOf(e)
699
735
  }
700
736
  };
701
737
  }
@@ -713,12 +749,13 @@ var ProcessSandboxExecutor = class {
713
749
  }, timeoutMs, (request) => this.#handleBridge(request, context), (host) => context.onWarning?.(`Network request blocked by sandbox network policy: ${host}`));
714
750
  if (!result.ok) {
715
751
  const stderrSummary = result.stderr?.length ? ` | stderr: ${result.stderr.join(" | ").slice(0, 500)}` : "";
752
+ const normalized = normalizeToolError(result.error?.code, result.error?.message ?? "script execution failed");
716
753
  return {
717
754
  ok: false,
718
755
  content: [],
719
756
  error: {
720
- code: result.error?.code ?? "TOOL_EXECUTION_FAILED",
721
- message: `${result.error?.message ?? "script execution failed"}${stderrSummary}`
757
+ code: normalized.code,
758
+ message: `${normalized.message}${stderrSummary}`
722
759
  }
723
760
  };
724
761
  }
@@ -737,7 +774,7 @@ var ProcessSandboxExecutor = class {
737
774
  content: [],
738
775
  error: {
739
776
  code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
740
- message: messageOf$5(e)
777
+ message: messageOf(e)
741
778
  }
742
779
  };
743
780
  } finally {
@@ -776,7 +813,7 @@ var ProcessSandboxExecutor = class {
776
813
  respond(bridgeError("unknown", "TOOL_EXECUTION_FAILED", "invalid bridge request"));
777
814
  return;
778
815
  }
779
- onBridge(request).then(respond, (e) => respond(bridgeError(request.id, "TOOL_EXECUTION_FAILED", messageOf$5(e))));
816
+ onBridge(request).then(respond, (e) => respond(bridgeError(request.id, "TOOL_EXECUTION_FAILED", messageOf(e))));
780
817
  return;
781
818
  }
782
819
  if (msg?.type === "load-result" || msg?.type === "execute-result") done(() => resolve(msg));
@@ -845,7 +882,7 @@ var ProcessSandboxExecutor = class {
845
882
  }
846
883
  }
847
884
  } catch (e) {
848
- return bridgeError(request.id, e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", messageOf$5(e));
885
+ return bridgeError(request.id, e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", messageOf(e));
849
886
  }
850
887
  }
851
888
  };
@@ -1177,7 +1214,6 @@ var CliUiBridge = class {
1177
1214
  return new Promise((resolve) => this.#waiters.push(resolve));
1178
1215
  }
1179
1216
  };
1180
- const messageOf$4 = (e) => e instanceof Error ? e.message : String(e);
1181
1217
  const toPlatform$1 = (p) => p.split("/").join(path.sep);
1182
1218
  const isZipArchive = (data) => data.length > 1 && data[0] === 80 && data[1] === 75;
1183
1219
  let tarModule$1;
@@ -1226,7 +1262,7 @@ async function extractTarFile(archivePath, destRoot, limits) {
1226
1262
  });
1227
1263
  } catch (e) {
1228
1264
  if (e instanceof WebSkillError) throw e;
1229
- throw new WebSkillError("INSTALL_FAILED", `Failed to extract tar archive: ${messageOf$4(e)}`, e);
1265
+ throw new WebSkillError("INSTALL_FAILED", `Failed to extract tar archive: ${messageOf(e)}`, e);
1230
1266
  }
1231
1267
  }
1232
1268
  /** 递归列出目录内全部文件(相对路径,posix 分隔,不含目录条目) */
@@ -1270,7 +1306,6 @@ async function removeDirQuiet(fs, dir) {
1270
1306
  if (await fs.exists(dir)) await fs.remove(dir, { recursive: true });
1271
1307
  } catch {}
1272
1308
  }
1273
- const messageOf$3 = (e) => e instanceof Error ? e.message : String(e);
1274
1309
  let tarModule;
1275
1310
  async function loadTar() {
1276
1311
  tarModule ??= await import("tar").catch((e) => {
@@ -1292,7 +1327,7 @@ async function exportArchive(fs, skillRoot, options) {
1292
1327
  return options.outPath;
1293
1328
  } catch (e) {
1294
1329
  if (e instanceof WebSkillError) throw e;
1295
- throw new WebSkillError("EXPORT_FAILED", `Failed to export ${options.format} archive: ${messageOf$3(e)}`, e);
1330
+ throw new WebSkillError("EXPORT_FAILED", `Failed to export ${options.format} archive: ${messageOf(e)}`, e);
1296
1331
  }
1297
1332
  }
1298
1333
  /** 只解出归档中的 webskill.skill-manifest.json 条目(安装前预览) */
@@ -1320,13 +1355,12 @@ async function readArchiveManifest(fs, archivePath) {
1320
1355
  }
1321
1356
  });
1322
1357
  } catch (e) {
1323
- throw new WebSkillError("EXPORT_FAILED", `Failed to read tar archive: ${messageOf$3(e)}`, e);
1358
+ throw new WebSkillError("EXPORT_FAILED", `Failed to read tar archive: ${messageOf(e)}`, e);
1324
1359
  }
1325
1360
  if (manifestText === void 0) throw notFound();
1326
1361
  return JSON.parse(manifestText);
1327
1362
  }
1328
1363
  const _execFileP = promisify(execFile);
1329
- const messageOf$2 = (e) => e instanceof Error ? e.message : String(e);
1330
1364
  /**
1331
1365
  * 跨平台执行命令:Windows 下对 npm 等 cmd 包装的命令通过 cmd.exe 代理执行,
1332
1366
  * git 等原生 exe 不受影响。默认 120s 超时(防挂死安装管线)。
@@ -1342,7 +1376,7 @@ async function execFileP(command, args, options = {}) {
1342
1376
  }
1343
1377
  /** 命令缺失/失败 → INSTALL_FAILED 结构化诊断 */
1344
1378
  function commandFailed(command, e) {
1345
- return new WebSkillError("INSTALL_FAILED", `Failed to run ${command}: ${e?.code === "ENOENT" ? `command "${command}" not found on this system` : messageOf$2(e)}`, e);
1379
+ return new WebSkillError("INSTALL_FAILED", `Failed to run ${command}: ${e?.code === "ENOENT" ? `command "${command}" not found on this system` : messageOf(e)}`, e);
1346
1380
  }
1347
1381
  /** git url 协议白名单:https:// 与 git@ SCP 形式;无 scheme 的本地路径放行(dev 工作流);
1348
1382
  * 其余显式协议(ext::/file:// 等)一律拒绝 */
@@ -1391,15 +1425,38 @@ async function stageGit(source, ctx) {
1391
1425
  function sha256Hex(data) {
1392
1426
  return createHash("sha256").update(data).digest("hex");
1393
1427
  }
1394
- const messageOf$1 = (e) => e instanceof Error ? e.message : String(e);
1395
- /** http 源:下载归档(Content-Length + 流式累计上限;可选 expectedSha256 校验包体)→ 解包 → 定位技能根/包集 */
1428
+ /** 重定向跳数上限(SSRF 防护:逐跳重新校验目标 URL) */
1429
+ const MAX_REDIRECT_HOPS = 3;
1430
+ /**
1431
+ * 带 SSRF 防护的下载:手动跟随重定向(默认 ≤3 跳),初始 URL 与每一跳目标
1432
+ * 都过 assertRemoteUrlAllowed(https 默认;私有/环回/链路本地默认拒绝)。
1433
+ */
1434
+ async function fetchWithSsrfGuard(source, fetchImpl) {
1435
+ const policy = {
1436
+ allowHttp: source.allowHttp ?? false,
1437
+ allowPrivateHosts: source.allowPrivateHosts ?? false
1438
+ };
1439
+ let url = assertRemoteUrlAllowed(source.url, policy);
1440
+ for (let hop = 0;; hop++) {
1441
+ const res = await fetchImpl(url.href, { redirect: "manual" });
1442
+ const location = res.headers.get("location");
1443
+ if (res.status >= 300 && res.status < 400 && location) {
1444
+ if (hop >= MAX_REDIRECT_HOPS) throw new WebSkillError("INSTALL_FAILED", `Download exceeded the redirect limit of ${MAX_REDIRECT_HOPS} hops`);
1445
+ url = assertRemoteUrlAllowed(new URL(location, url).href, policy);
1446
+ continue;
1447
+ }
1448
+ return res;
1449
+ }
1450
+ }
1451
+ /** http 源:下载归档(SSRF 防护 + Content-Length/流式上限;可选 expectedSha256 校验包体)→ 解包 → 定位技能根/包集 */
1396
1452
  async function stageHttp(source, ctx, expectedSha256) {
1397
1453
  const fetchImpl = ctx.fetchImpl ?? fetch;
1398
1454
  let res;
1399
1455
  try {
1400
- res = await fetchImpl(source.url);
1456
+ res = await fetchWithSsrfGuard(source, fetchImpl);
1401
1457
  } catch (e) {
1402
- throw new WebSkillError("INSTALL_FAILED", `Download failed: ${messageOf$1(e)}`, e);
1458
+ if (e instanceof WebSkillError) throw e;
1459
+ throw new WebSkillError("INSTALL_FAILED", `Download failed: ${messageOf(e)}`, e);
1403
1460
  }
1404
1461
  if (!res.ok) throw new WebSkillError("INSTALL_FAILED", `Download failed with HTTP ${res.status}`);
1405
1462
  const data = await readResponseWithLimit(res, ctx.archiveLimits);
@@ -1448,13 +1505,19 @@ async function stageLocal(source, ctx) {
1448
1505
  /** npm 包名(@scope/name 或 name,小写字母数字 . _ - /)与版本(semver 或 dist-tag)正则 */
1449
1506
  const PACKAGE_NAME_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/;
1450
1507
  const VERSION_RE = /^[a-z0-9._~^>=<x*+-]+$/i;
1508
+ /**
1509
+ * 本地路径字符白名单(Windows 下 npm 经 cmd /c 代理二次解析,
1510
+ * `& | < > ^ " ' % ! ;` 与空格一律拒绝;允许 Windows 盘符 `C:\` 与正/反斜杠)
1511
+ */
1512
+ const LOCAL_PATH_RE = /^[A-Za-z0-9._~+@/\\:-]+$/;
1451
1513
  function assertNpmSpecSafe(source) {
1452
1514
  const name = source.packageName;
1453
1515
  const isLocalPath = name.startsWith("/") || name.startsWith("./") || name.startsWith("../");
1454
1516
  if (name.startsWith("--") || !PACKAGE_NAME_RE.test(name) && !isLocalPath) throw new WebSkillError("INSTALL_FAILED", `Invalid npm package name: ${JSON.stringify(name)}`);
1517
+ if (isLocalPath && !LOCAL_PATH_RE.test(name)) throw new WebSkillError("INSTALL_FAILED", `Local npm path contains characters outside the safe whitelist: ${JSON.stringify(name)}`);
1455
1518
  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)}`);
1456
1519
  }
1457
- /** npm 源:npm pack → 解 tar.gz;技能根取包的 skill/ 子目录(存在时)否则包根;spec 前插 `--` 防选项注入 */
1520
+ /** npm 源:npm pack --ignore-scripts → 解 tar.gz;技能根取包的 skill/ 子目录(存在时)否则包根;spec 前插 `--` 防选项注入 */
1458
1521
  async function stageNpm(source, ctx) {
1459
1522
  assertNpmSpecSafe(source);
1460
1523
  const spec = source.version ? `${source.packageName}@${source.version}` : source.packageName;
@@ -1462,6 +1525,7 @@ async function stageNpm(source, ctx) {
1462
1525
  try {
1463
1526
  ({stdout} = await execFileP("npm", [
1464
1527
  "pack",
1528
+ "--ignore-scripts",
1465
1529
  "--pack-destination",
1466
1530
  ctx.stagingRoot,
1467
1531
  "--",
@@ -1578,7 +1642,6 @@ async function verifyIntegrity(fs, skillRoot) {
1578
1642
  for (const rel of actualFiles) actualHashes.set(rel, sha256Hex(await fs.readBinary(`${skillRoot}/${rel}`)));
1579
1643
  return verifyManifest(manifest, actualHashes, actualFiles);
1580
1644
  }
1581
- const messageOf = (e) => e instanceof Error ? e.message : String(e);
1582
1645
  const asInstallFailed = (e) => e instanceof WebSkillError && e.code === "INSTALL_FAILED" ? e : new WebSkillError("INSTALL_FAILED", `Install failed: ${messageOf(e)}`, e);
1583
1646
  /**
1584
1647
  * 技能管理门面:统一安装管线(staging → 解析 name → 校验 → 拷贝 → manifest → lockfile),