@1e0zj/dsh-plugin-mall 0.1.10 → 0.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -43,10 +43,13 @@ window.__ModuleLoader__.load({
43
43
  ".mkt_cardActions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:auto;padding-top:4px}",
44
44
  ".mkt_cardActions .mkt_btn{text-decoration:none}",
45
45
  ".mkt_panelTitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}",
46
+ ".mkt_panelRow{display:flex;align-items:center;gap:8px}",
47
+ ".mkt_panelRow .mkt_link{margin-left:auto}",
46
48
  ".mkt_pre{font-family:Consolas,Monaco,monospace;font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-secondary,#f6f7f8);border-radius:6px;padding:8px;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all}",
47
49
  ".mkt_ok{color:var(--dsw-alias-state-success-primary,#2f855a)}",
48
50
  ".mkt_badge{display:inline-block;border-radius:999px;padding:1px 8px;font-size:11px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}",
49
51
  ".mkt_badgeOk{border-color:var(--dsw-alias-state-success-primary,#2f855a);color:var(--dsw-alias-state-success-primary,#2f855a)}",
52
+ ".mkt_badgeBad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}",
50
53
  ".mkt_check{display:flex;align-items:center;gap:4px;font-size:12.5px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap}",
51
54
  ".mkt_link{color:var(--dsw-alias-state-business-primary);font-size:12px;text-decoration:none;cursor:pointer}",
52
55
  ".mkt_installedHead{display:flex;align-items:center;gap:8px;width:100%;background:none;border:0;padding:0;margin:0;cursor:pointer;font:inherit;text-align:left}",
@@ -127,7 +130,11 @@ window.__ModuleLoader__.load({
127
130
  jobsRef.current = Object.assign({}, jobsRef.current, { [id]: { status: "running", spec: spec, output: "" } });
128
131
  setJobs(Object.assign({}, jobsRef.current));
129
132
  }, []);
130
- return { jobs: jobs, track: track };
133
+ var clear = useCallback(function () {
134
+ jobsRef.current = {};
135
+ setJobs({});
136
+ }, []);
137
+ return { jobs: jobs, track: track, clear: clear };
131
138
  }
132
139
 
133
140
  // ── plugin verification badge ───────────────────────────────────────────
@@ -135,6 +142,9 @@ window.__ModuleLoader__.load({
135
142
  // dsh.bundle/dsh.client 声明,进程内缓存)。unknown 不显示徽章。
136
143
  function verifyBadge(verified) {
137
144
  if (verified === undefined || verified === null) return null;
145
+ if (verified.hostDeps !== undefined && verified.hostDeps.length > 0) {
146
+ return h("span", { className: "mkt_badge mkt_badgeBad", title: verified.hostDeps.join(", ") }, "宿主依赖风险");
147
+ }
138
148
  if (verified.kind === "bundle") return h("span", { className: "mkt_badge mkt_badgeOk" }, "宿主插件");
139
149
  if (verified.kind === "client") return h("span", { className: "mkt_badge mkt_badgeOk" }, "UI插件");
140
150
  if (verified.kind === "plain") return h("span", { className: "mkt_badge" }, "未声明");
@@ -181,7 +191,10 @@ window.__ModuleLoader__.load({
181
191
  var ids = Object.keys(props.jobs);
182
192
  if (ids.length === 0) return null;
183
193
  return h("div", { className: "mkt_card" },
184
- h("p", { className: "mkt_panelTitle" }, "任务"),
194
+ h("div", { className: "mkt_panelRow" },
195
+ h("p", { className: "mkt_panelTitle" }, "任务"),
196
+ props.onClear ? h("span", { className: "mkt_link", onClick: props.onClear }, "清空") : null
197
+ ),
185
198
  ids.map(function (id) {
186
199
  var job = props.jobs[id];
187
200
  var done = job.status === "completed" || job.status === "failed" || job.status === "killed";
@@ -338,11 +351,16 @@ window.__ModuleLoader__.load({
338
351
  // 无限滚动:哨兵进入视口时拉下一页并追加。GitHub topic 的翻页间数据
339
352
  // 可能移动造成重复,按 fullName 去重;已显示数追上 total 即到底。
340
353
  var canLoadMore = results !== null && results.items.length < results.total && !reachedLimit;
354
+ var loadMoreLock = useRef(false);
341
355
  var loadMore = useCallback(function () {
342
356
  if (!canLoadMore || loading || loadingMore) return;
357
+ // 同一 tick 内 observer 可能连续触发两次,state 更新不阻塞闭包读值,
358
+ // 用 ref 做重入闸。
359
+ if (loadMoreLock.current) return;
343
360
  // 限流熔断:失败后 60s 内不再自动请求(哨兵重渲染会反复触发
344
361
  // IntersectionObserver,不熔断会连环 500 直到限流窗口过去)。
345
362
  if (Date.now() < retryAt) return;
363
+ loadMoreLock.current = true;
346
364
  setLoadingMore(true);
347
365
  call("search", { query: query, sort: sort, perPage: 20, page: page + 1 }).then(function (value) {
348
366
  if (value.truncated === true) { setReachedLimit(true); return; }
@@ -363,6 +381,7 @@ window.__ModuleLoader__.load({
363
381
  setError(errorText(e));
364
382
  setRetryAt(Date.now() + 60000);
365
383
  }).finally(function () {
384
+ loadMoreLock.current = false;
366
385
  setLoadingMore(false);
367
386
  });
368
387
  }, [call, canLoadMore, loading, loadingMore, page, query, sort, retryAt]);
@@ -393,6 +412,7 @@ window.__ModuleLoader__.load({
393
412
  var polling = useJobPolling(call, refreshInstalled);
394
413
  var track = polling.track;
395
414
  var jobs = polling.jobs;
415
+ var clearJobs = polling.clear;
396
416
 
397
417
  useEffect(function () {
398
418
  doSearch();
@@ -437,7 +457,12 @@ window.__ModuleLoader__.load({
437
457
  var tries = 0;
438
458
  var ping = setInterval(function () {
439
459
  tries++;
440
- if (tries > 40) { clearInterval(ping); return; }
460
+ if (tries > 40) {
461
+ clearInterval(ping);
462
+ setRestarting(false);
463
+ setError("2 分钟内未检测到 dsh 重启完成,请手动检查 dsh 状态后刷新页面。");
464
+ return;
465
+ }
441
466
  rpc.call("/market", "installed", {}).then(function () {
442
467
  clearInterval(ping);
443
468
  window.location.reload();
@@ -472,16 +497,15 @@ window.__ModuleLoader__.load({
472
497
  refreshInstalled();
473
498
  }, [refreshInstalled]);
474
499
 
475
- // 活动任务置顶(紧跟已装面板,点安装立刻看到进度);全部静止后归档到底部当日志。
476
- var jobsActive = false;
477
- for (var jid in jobs) { if (jobs[jid] && jobs[jid].status === "running") { jobsActive = true; break; } }
478
-
500
+ // 任务面板位置固定(已装面板之后):不随任务状态在顶部/底部之间
501
+ // 跳——npm 安装几秒即完成,"完成即落底"曾让结果凭空消失。
479
502
  // "只看已验证"开关下的可见项:verify 判定 bundle/client 的才算插件。
480
503
  // verifyPending:当前加载的仓库里还有未验证完的(verified map 尚未覆盖)。
481
504
  var visibleItems = results === null ? [] : results.items.filter(function (it) {
482
505
  if (verifiedOnly !== true) return true;
483
506
  var v = verified[it.fullName];
484
- return v !== undefined && (v.kind === "bundle" || v.kind === "client");
507
+ return v !== undefined && (v.kind === "bundle" || v.kind === "client")
508
+ && !(v.hostDeps !== undefined && v.hostDeps.length > 0);
485
509
  });
486
510
  var verifyPending = results !== null && results.items.some(function (it) { return verified[it.fullName] === undefined; });
487
511
 
@@ -516,7 +540,7 @@ window.__ModuleLoader__.load({
516
540
  ),
517
541
  error ? h("div", { className: "mkt_error" }, error) : null,
518
542
  h(InstalledPanel, { installed: installed, removing: removing, updates: updates, onUninstall: doUninstall, onInstallSpec: doInstallSpec }),
519
- jobsActive ? h(JobsPanel, { jobs: jobs }) : null,
543
+ h(JobsPanel, { jobs: jobs, onClear: clearJobs }),
520
544
  h("div", { className: "mkt_list" },
521
545
  results == null
522
546
  ? h("div", { className: "mkt_meta mkt_listHead" }, loading ? "正在加载最热插件…" : "—")
@@ -552,8 +576,7 @@ window.__ModuleLoader__.load({
552
576
  ? h("div", { className: "mkt_loadMore", ref: sentinelRef }, loadingMore ? "加载中…" : Date.now() < retryAt ? "GitHub 限流中,稍后再下滑加载" : "下滑加载更多")
553
577
  : h("div", { className: "mkt_loadMore" }, reachedLimit ? "已达 GitHub 搜索上限(前 1000 个结果)" : "已显示全部 " + results.items.length + " 个")
554
578
  )
555
- ),
556
- jobsActive ? null : h(JobsPanel, { jobs: jobs })
579
+ )
557
580
  );
558
581
  }
559
582
 
package/src/github.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // GitHub API helpers for the dsh plugin marketplace.
2
2
  // Pure functions with no harness imports, so this module is unit-testable
3
3
  // standalone (node src/github.js --self-test).
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
4
6
 
5
7
  const SEARCH_TOPIC = "topic:dsh-plugin";
6
8
  /** GitHub search never serves past the first 1000 results. */
@@ -116,34 +118,41 @@ export async function searchPlugins({ query, sort = "stars", perPage = 10, page
116
118
  // to the explicit github: spec).
117
119
 
118
120
  const NPM_REGISTRY = "https://registry.npmjs.org";
119
- const npmCache = new Map(); // name -> {latest, repositoryUrl} | null (unknown/not found)
121
+ const npmCache = new Map(); // name -> {info, at} null misses expire after 5 min
120
122
 
121
123
  /**
122
- * Look up a package on the npm registry (abbreviated metadata). Cached for
123
- * the process lifetime; null means "not on npm / unreachable".
124
+ * Look up a package on the npm registry (abbreviated metadata). Successful
125
+ * lookups cache for the process lifetime; "not found / unreachable" (null)
126
+ * expires after 5 minutes so a transient registry failure self-heals.
124
127
  */
125
128
  export async function npmPackageInfo(name) {
126
129
  const clean = String(name ?? "").trim();
127
130
  if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
128
- if (npmCache.has(clean)) return npmCache.get(clean);
131
+ const cached = npmCache.get(clean);
132
+ if (cached !== undefined && (cached.info !== null || Date.now() - cached.at < 300000)) return cached.info;
129
133
  let info = null;
130
134
  try {
131
- const response = await fetch(`${NPM_REGISTRY}/${clean.replace("/", "%2F")}`, {
132
- headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/vnd.npm.install-v1+json" },
135
+ // 单版本端点返回 latest 的完整 manifest(dependencies + repository 都在)。
136
+ // abbreviated packument 不含 repository,防抢注比对会永远落空。
137
+ const response = await fetch(`${NPM_REGISTRY}/${clean.replace("/", "%2F")}/latest`, {
138
+ headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
133
139
  });
134
140
  if (response.ok) {
135
141
  const body = await response.json();
136
- const latest = body?.["dist-tags"]?.latest;
137
- if (typeof latest === "string") {
138
- const rawRepository = body?.repository;
142
+ if (typeof body?.version === "string") {
143
+ const rawRepository = body.repository;
139
144
  const repositoryUrl = typeof rawRepository === "string" ? rawRepository : rawRepository?.url;
140
- info = { latest, repositoryUrl: typeof repositoryUrl === "string" ? repositoryUrl : undefined };
145
+ info = {
146
+ latest: body.version,
147
+ repositoryUrl: typeof repositoryUrl === "string" ? repositoryUrl : undefined,
148
+ hostDeps: hostShadowDependencies(body),
149
+ };
141
150
  }
142
151
  }
143
152
  } catch {
144
153
  info = null; // registry unreachable — caller falls back
145
154
  }
146
- npmCache.set(clean, info);
155
+ npmCache.set(clean, { info, at: Date.now() });
147
156
  return info;
148
157
  }
149
158
 
@@ -166,7 +175,64 @@ export async function preferNpmSpec({ spec }) {
166
175
  return pointsBack ? declaredName : raw;
167
176
  }
168
177
 
169
- /** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts read as 0. */
178
+ /**
179
+ * Refuse to install a package whose `dependencies` shadow host framework
180
+ * packages. Host copies inside a profile split module identities and crash
181
+ * all tool scheduling — the dsh contract is peerDependencies, but the host
182
+ * enforces nothing, so the marketplace is the last line of defense.
183
+ * Sources: registry abbreviated metadata for npm names, the verify cache
184
+ * (raw package.json) for github: specs.
185
+ */
186
+ /**
187
+ * Extract the bare npm package name: "name", "name@version", "@s/n@version"
188
+ * → "name"/"@s/n". Returns null for anything that is not an npm name shape.
189
+ */
190
+ function npmNameOf(raw) {
191
+ if (typeof raw !== "string" || raw.length === 0) return null;
192
+ if (raw.startsWith("@")) {
193
+ const match = /^(@[^/@\s]+\/[^/@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
194
+ return match === null ? null : match[1];
195
+ }
196
+ const match = /^([^@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
197
+ return match === null ? null : match[1];
198
+ }
199
+
200
+ export async function assertSafeToInstall({ spec }) {
201
+ const raw = String(spec ?? "");
202
+ let hostDeps;
203
+ if (/^(?:file:|link:)/i.test(raw)) {
204
+ // 本地路径直通:直接读它的 package.json 查 dependencies。
205
+ const dir = raw.replace(/^(?:file:|link:)/i, "").replace(/[\\/]+$/, "");
206
+ try {
207
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
208
+ hostDeps = hostShadowDependencies(pkg);
209
+ } catch {
210
+ return; // 读不到清单——pnpm 会给出真实错误,这里放行
211
+ }
212
+ } else if (/^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.test(raw)) {
213
+ const repo = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(raw)[1];
214
+ const { results } = await verifyPlugins({ repos: [repo] });
215
+ hostDeps = results[repo]?.hostDeps;
216
+ } else if (/^https?:\/\//i.test(raw)) {
217
+ return; // 远程 tarball 下载前无法廉价检查;罕见路径,放行
218
+ } else {
219
+ // npm 名(含带版本形式):剥掉 @version 再查。
220
+ const name = npmNameOf(raw);
221
+ if (name === null || name.length === 0) {
222
+ // 无法识别形状的 spec 一律拒绝,而不是静默跳过检查。
223
+ throw new Error(`cannot analyze install spec ${JSON.stringify(raw)} for host-shadow dependencies — refusing to install`);
224
+ }
225
+ const info = await npmPackageInfo(name);
226
+ hostDeps = info?.hostDeps;
227
+ }
228
+ if (hostDeps !== undefined && hostDeps !== null && hostDeps.length > 0) {
229
+ throw new Error(`${raw} declares ${hostDeps.length} host framework package(s) as dependencies (${hostDeps.slice(0, 3).join(", ")}${hostDeps.length > 3 ? ", …" : ""}) — installing it would duplicate host modules and crash dsh tool scheduling. Ask the plugin author to move @deepseek-ai/* to peerDependencies.`);
230
+ }
231
+ }
232
+
233
+ /** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts
234
+ * read as 0; numerically equal versions where one carries a pre-release
235
+ * segment ("-rc.1") sort below the plain release ("2.0.0-beta" < "2.0.0"). */
170
236
  export function compareVersions(a, b) {
171
237
  const pa = String(a ?? "").split(".");
172
238
  const pb = String(b ?? "").split(".");
@@ -175,6 +241,9 @@ export function compareVersions(a, b) {
175
241
  const nb = Number(pb[index]) || 0;
176
242
  if (na !== nb) return na < nb ? -1 : 1;
177
243
  }
244
+ const preA = String(a ?? "").includes("-");
245
+ const preB = String(b ?? "").includes("-");
246
+ if (preA !== preB) return preA ? -1 : 1;
178
247
  return 0;
179
248
  }
180
249
  // ── plugin verification (raw CDN, no API quota) ─────────────────────────────
@@ -222,15 +291,37 @@ async function fetchRawPackageJson(repo, signal) {
222
291
  throw lastError ?? new Error("no raw source reachable");
223
292
  }
224
293
 
294
+ /**
295
+ * Framework packages the host provides via the profiles/node_modules fallback.
296
+ * A plugin declaring any of these as `dependencies` (instead of
297
+ * peerDependencies) installs real copies into the profile — the loader then
298
+ * holds two module instances, symbol identities split, and every tool call
299
+ * crashes with an undiagnosable "reading 'prepare'". See installer docs.
300
+ */
301
+ const HOST_PACKAGES = /^(@deepseek-ai\/|cosmokit$|schemastery$)/;
302
+
303
+ /** The @deepseek-ai/* (and cordis runtime) entries inside `dependencies`. */
304
+ function hostShadowDependencies(pkg) {
305
+ if (pkg === undefined || typeof pkg !== "object") return undefined;
306
+ const deps = Object.keys(pkg.dependencies ?? {});
307
+ const host = deps.filter((name) => HOST_PACKAGES.test(name));
308
+ return host.length > 0 ? host : undefined;
309
+ }
310
+
225
311
  /**
226
312
  * Verify repositories as real dsh plugins by their package.json declaration.
227
313
  * @param {{repos: string[], signal?: AbortSignal}} options - "owner/name" list.
228
- * @returns the {results} map: fullName -> {kind: "bundle"|"client"|"plain"|"no-manifest"|"unknown", name?, version?}.
314
+ * @returns the {results} map: fullName -> {kind: "bundle"|"client"|"plain"|"no-manifest"|"unknown", name?, version?, hostDeps?}.
229
315
  */
230
316
  export async function verifyPlugins({ repos, signal }) {
231
317
  const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
232
318
  .filter((repo) => /^[^/\s]+\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
233
- const pending = wanted.filter((repo) => !verifyCache.has(repo));
319
+ // "unknown"(网络失败)60s 后过期重试;成功结果进程内永久缓存。
320
+ const pending = wanted.filter((repo) => {
321
+ const cached = verifyCache.get(repo);
322
+ if (cached === undefined) return true;
323
+ return cached.kind === "unknown" && Date.now() - (cached.ts ?? 0) > 60000;
324
+ });
234
325
  let cursor = 0;
235
326
  const worker = async () => {
236
327
  while (cursor < pending.length) {
@@ -241,16 +332,21 @@ export async function verifyPlugins({ repos, signal }) {
241
332
  : typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
242
333
  : pkg.dsh?.client !== undefined ? "client"
243
334
  : "plain";
244
- verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version });
335
+ verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg) });
245
336
  } catch (error) {
246
337
  if (error?.name === "AbortError") throw error;
247
- verifyCache.set(repo, { kind: "unknown" });
338
+ verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
248
339
  }
249
340
  }
250
341
  };
251
342
  await Promise.all(Array.from({ length: Math.min(VERIFY_CONCURRENCY, pending.length) }, worker));
252
343
  const results = {};
253
- for (const repo of wanted) results[repo] = verifyCache.get(repo) ?? { kind: "unknown" };
344
+ for (const repo of wanted) {
345
+ const cached = verifyCache.get(repo);
346
+ results[repo] = cached === undefined || cached.ts !== undefined
347
+ ? { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps }
348
+ : cached;
349
+ }
254
350
  return { results };
255
351
  }
256
352
 
package/src/index.js CHANGED
@@ -19,8 +19,8 @@ import { existsSync, readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { spawn } from "node:child_process";
21
21
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
22
- import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions } from "./github.js";
23
- import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker } from "./installer.js";
22
+ import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall } from "./github.js";
23
+ import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker, assertSafeSpec } from "./installer.js";
24
24
 
25
25
  export const name = "@1e0zj/dsh-plugin-mall";
26
26
  export const inject = ["tools", "jobs", "systemPrompt"];
@@ -206,12 +206,20 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
206
206
  let spec;
207
207
  try {
208
208
  spec = normalizeSpec(payload?.spec);
209
+ assertSafeSpec(spec);
209
210
  } catch (error) {
210
211
  return rpcFail(error);
211
212
  }
212
213
  // npm tarball 优先(小而快、带 integrity);registry 条目不同源的包名
213
214
  // 视为抢注,回退 github: 全仓库 spec。
214
215
  spec = await preferNpmSpec({ spec });
216
+ // 宿主依赖硬拦:dependencies 里拖着 @deepseek-ai/* 的包装进 profile
217
+ // 就是双模块实例 + 全工具调度崩溃(宿主无任何护栏,市场是最后防线)。
218
+ try {
219
+ await assertSafeToInstall({ spec });
220
+ } catch (error) {
221
+ return rpcFail(error);
222
+ }
215
223
  try {
216
224
  const profileDir = resolveProfileDir(profile);
217
225
  if (!existsSync(join(profileDir, "package.json"))) ensureProfile(profile);
@@ -229,6 +237,11 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
229
237
  const profile = String(payload?.profile ?? defaultProfile).trim();
230
238
  const packageName = String(payload?.package ?? "").trim();
231
239
  if (packageName.length === 0) return rpcFail(new Error("uninstall: package name is required"));
240
+ try {
241
+ assertSafeSpec(packageName);
242
+ } catch (error) {
243
+ return rpcFail(error);
244
+ }
232
245
  try {
233
246
  const profileDir = resolveProfileDir(profile);
234
247
  if (!existsSync(join(profileDir, "package.json"))) {
@@ -267,7 +280,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
267
280
  : `sleep 2 && ${relaunch}`;
268
281
  const child = spawn(launcher, { shell: true, detached: true, stdio: "ignore", cwd: process.cwd(), windowsHide: true });
269
282
  child.unref();
270
- setTimeout(() => process.exit(0), 800);
283
+ setTimeout(() => process.exit(0), 1500);
271
284
  return rpcOk({ restarting: true });
272
285
  }
273
286
  case "jobCancel": {
@@ -418,7 +431,10 @@ export function apply(ctx, config = {}) {
418
431
  },
419
432
  async execute(args, exec) {
420
433
  const profile = String(args.profile ?? defaultProfile).trim();
421
- const spec = await preferNpmSpec({ spec: normalizeSpec(args.spec) });
434
+ const normalized = normalizeSpec(args.spec);
435
+ assertSafeSpec(normalized);
436
+ const spec = await preferNpmSpec({ spec: normalized });
437
+ await assertSafeToInstall({ spec });
422
438
  let profileDir;
423
439
  try {
424
440
  profileDir = resolveProfileDir(profile);
@@ -473,6 +489,7 @@ export function apply(ctx, config = {}) {
473
489
  const profile = String(args.profile ?? defaultProfile).trim();
474
490
  const packageName = String(args.package ?? "").trim();
475
491
  if (packageName.length === 0) throw new Error("market_uninstall: package name is required");
492
+ assertSafeSpec(packageName);
476
493
  try {
477
494
  resolveProfileDir(profile);
478
495
  } catch (error) {
package/src/installer.js CHANGED
@@ -361,6 +361,20 @@ export function createJobTracker() {
361
361
  };
362
362
  }
363
363
 
364
+ // ── spec shape guard ────────────────────────────────────────────────────────
365
+
366
+ // Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
367
+ // 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
368
+ // shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。
369
+ const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
370
+
371
+ /** Reject install/remove specs carrying shell metacharacters. */
372
+ export function assertSafeSpec(spec) {
373
+ if (UNSAFE_SPEC_RE.test(String(spec ?? ""))) {
374
+ throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(String(spec))}`);
375
+ }
376
+ }
377
+
364
378
  // ── pnpm self-heal (corepack) ───────────────────────────────────────────────
365
379
 
366
380
  /**
@@ -565,10 +579,12 @@ export function runRemove({ profile, packageName }, selfHealed = false) {
565
579
  proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
566
580
  }).then(async (outcome) => {
567
581
  if (outcome.spawnError !== undefined) {
568
- // pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑)。
582
+ // pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑,
583
+ // 这里必须返回它的 done outcome——返回 producer 本体会让 tracker
584
+ // 把成功任务记成 failed)。
569
585
  if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
570
- const healed = await enablePnpmViaCorepack(() => {});
571
- if (healed) return runRemove({ profile, packageName }, true);
586
+ const healed = await enablePnpmViaCorepack(push);
587
+ if (healed) return await runRemove({ profile, packageName }, true).done;
572
588
  }
573
589
  const hint = outcome.spawnError.code === "ENOENT"
574
590
  ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"