@1e0zj/dsh-plugin-mall 0.4.12 → 0.4.15

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
@@ -128,6 +128,44 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
128
128
  > `dsh-plugin-guard recover` does repair this.
129
129
 
130
130
 
131
+ ## Update stuck at the install-script approval card? (updates *to* 0.4.15)
132
+
133
+ Symptom: updating the marketplace itself pauses at "安装期代码需要确认", and
134
+ clicking 允许 fails with `allowBuildScripts cannot be specified on initial
135
+ install`. Clicking again does not help.
136
+
137
+ Who hits it: only profiles that have **never approved a dependency with install
138
+ scripts** (in practice `node-pty`, pulled in transitively). If your profile's
139
+ `pnpm-workspace.yaml` already carries `allowBuilds: {node-pty: true}` — most
140
+ profiles that have installed anything do — the approval card never appears and
141
+ the update just works.
142
+
143
+ Cause: pnpm installs the new marketplace package before the approval gate, which
144
+ swaps the running plugin on disk; dsh replays the assembly tree and the whole
145
+ marketplace UI remounts, losing the browser session the approval token was
146
+ issued to. The token is then unrecoverable, so the retry arrives without it.
147
+
148
+ Recovery — restart dsh (startup recovery rolls the paused install back), then
149
+ either update from the CLI:
150
+
151
+ ```bash
152
+ # <profile> = web, guard-test, ...
153
+ dsh plugin --profile <profile> add @1e0zj/dsh-plugin-mall@0.4.15
154
+ ```
155
+
156
+ or pre-approve the build scripts once and update from the page as usual:
157
+
158
+ ```yaml
159
+ # <profile>/pnpm-workspace.yaml
160
+ allowBuilds:
161
+ 'node-pty@1.1.0': true
162
+ ```
163
+
164
+ > Fixed from 0.4.15 onward — the session identity now survives a remount. The
165
+ > fix cannot rescue the hop *to* 0.4.15 itself: during that update both the
166
+ > page's pre-swap code and the running dsh process are still the old version.
167
+
168
+
131
169
  ## Agent tools
132
170
 
133
171
  | Tool | What it does |
@@ -282,6 +320,40 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
282
320
  > `dsh-plugin-guard recover` 确实能修这个故障。
283
321
 
284
322
 
323
+ ## 更新卡在批准卡上?(更新**到** 0.4.15 时可能遇到)
324
+
325
+ 症状:更新市场自己,停在「安装期代码需要确认」,点「允许」报
326
+ `allowBuildScripts cannot be specified on initial install`,再点还是这样。
327
+
328
+ 谁会遇到:只有**从未批准过带安装脚本的依赖**的 profile(实际就是传递依赖
329
+ `node-pty`)。如果你的 `pnpm-workspace.yaml` 里已经有
330
+ `allowBuilds: {node-pty: true}`——装过东西的 profile 基本都有——批准卡根本
331
+ 不会出现,更新一次过。
332
+
333
+ 原因:pnpm 在批准闸之前就把新版市场装进了 node_modules,正在运行的插件实体
334
+ 被替换,dsh 随之重放装配树、整个市场 UI 重挂载,签发审批 token 时的那个浏览器
335
+ session 一起没了。token 从此取不回来,重试自然是「没带 token」。
336
+
337
+ 出路——重启 dsh(启动恢复会把暂停的安装撤回),然后二选一。命令行更新:
338
+
339
+ ```bash
340
+ # <profile> = web、guard-test……
341
+ dsh plugin --profile <profile> add @1e0zj/dsh-plugin-mall@0.4.15
342
+ ```
343
+
344
+ 或者先把构建脚本批准一次,再照常从页面更新:
345
+
346
+ ```yaml
347
+ # <profile>/pnpm-workspace.yaml
348
+ allowBuilds:
349
+ 'node-pty@1.1.0': true
350
+ ```
351
+
352
+ > 0.4.15 起已修复——session 身份现在扛得住重挂载。但这个修复救不了「更新到
353
+ > 0.4.15」这一跳本身:那次更新时,页面上 swap 之前的代码和正在跑的 dsh 进程
354
+ > 都还是旧版。
355
+
356
+
285
357
  ## 工作原理
286
358
 
287
359
  - 双面包(dual-face)插件:`dsh.bundle` 半边挂在 **host 平面**(profile bundle 层),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.4.12",
3
+ "version": "0.4.15",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cli.js CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  resolveDshHome,
62
62
  rollbackPendingSnapshot,
63
63
  validateInstalledProfile,
64
+ validatePendingProfile,
64
65
  validateRemoveCompletion,
65
66
  } from "./guard.js";
66
67
  // github.js imports node builtins only — the host-independence of this CLI is
@@ -502,7 +503,7 @@ async function cmdAdd({ spec, profile, home, acceptWarnings }) {
502
503
  // rolled back immediately; otherwise the marker stays pending and the next
503
504
  // dsh startup (or `guard recover`) commits it once the plugin actually
504
505
  // loads.
505
- const validation = validateInstalledProfile(profileDir);
506
+ const validation = validatePendingProfile(profileDir);
506
507
  if (result.exitCode === 0 && validation.ok) {
507
508
  console.log(`[guard] installed ${spec} into profile "${profile}".`);
508
509
  console.log("Restart dsh to load it. On the next startup — or via `node src/cli.js guard recover` — the pending snapshot is committed once the profile proves loadable; if dsh fails to boot, the same command rolls it back.");
@@ -596,7 +597,7 @@ async function cmdRemove({
596
597
 
597
598
  let validation;
598
599
  try {
599
- validation = validateInstalledProfile(profileDir);
600
+ validation = validatePendingProfile(profileDir);
600
601
  } catch (error) {
601
602
  rollbackAndThrow(`remove completed but static profile validation threw: ${error.message}`);
602
603
  }
@@ -1327,7 +1328,7 @@ async function cmdLaunch({
1327
1328
  }
1328
1329
  let validation;
1329
1330
  try {
1330
- validation = validateInstalledProfile(profileDir);
1331
+ validation = validatePendingProfile(profileDir, marker);
1331
1332
  } catch (error) {
1332
1333
  throw new Error(`static validation of profile "${profile}" failed — refusing to launch: ${error.message}`);
1333
1334
  }
package/src/client.js CHANGED
@@ -157,6 +157,36 @@ window.__ModuleLoader__.load({
157
157
  throw new Error("Secure browser randomness is unavailable; plugin marketplace actions are disabled");
158
158
  }
159
159
 
160
+ // 与 src/index.js 的 BROWSER_SESSION_RE 保持一致:window 上的值同样是
161
+ // 不可信输入(别的脚本能写),读出来必须重新过格式校验。
162
+ var BROWSER_SESSION_RE = /^sess_(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i;
163
+ var SESSION_NONCE_PROP = "__dshPluginMallSession";
164
+ // nonce 挂在 window 上(进程内,不落任何存储)。
165
+ //
166
+ // 要解决的:更新插件市场自身会替换正在运行的市场包,组装树随之重放、
167
+ // 整个市场 UI 重挂载——useRef 里的 nonce 归零,而审批 token 只发给签发
168
+ // 时的同一个 session,于是 needsApproval 暂停的任务在重挂载后永远拿不回
169
+ // token,用户点「允许」必报 initial install。重挂载是 React 层的(页面
170
+ // 没有重新加载),window 对象自始至终是同一个,进程内值正好覆盖它。
171
+ //
172
+ // 不用 sessionStorage:它在 window.open(同源) 和浏览器「复制标签页」时
173
+ // 会被复制给新标签页。jobs 镜像里本就带着 approvalToken,nonce 再一起
174
+ // 复制过去,新标签页就同时拿到了 token 和它的归属身份——后端会认成同一
175
+ // 个 session,跨 tab 隔离(47db0c4)就此失效。window 上的值不随标签页
176
+ // 复制,新标签页天然是新身份。
177
+ //
178
+ // 代价:页面刷新(F5)后 nonce 重新生成,刷新前签发的 token 作废。这是
179
+ // 对的边界——刷新是用户主动重来,暂停的任务重启 dsh 即可撤回;而重挂载
180
+ // 是用户没做任何事的情况下被系统掀了桌子,那才是必须兜住的。
181
+ function readOrCreateBrowserSession(host) {
182
+ var scope = host || window;
183
+ var current = scope[SESSION_NONCE_PROP];
184
+ if (typeof current === "string" && BROWSER_SESSION_RE.test(current)) return current;
185
+ var nonce = generateSessionNonce();
186
+ scope[SESSION_NONCE_PROP] = nonce;
187
+ return nonce;
188
+ }
189
+
160
190
  // ── job polling ─────────────────────────────────────────────────────────
161
191
  // onPreflightSettled:预检 job(kind=dsh-plugin-preflight)落定时回调,
162
192
  // 携带 (spec, report)。safe 由调用方直接续装;有风险由调用方出内联卡片。
@@ -443,7 +473,7 @@ window.__ModuleLoader__.load({
443
473
  meta: clip(props.spec || "", 50),
444
474
  actions: actions,
445
475
  },
446
- props.detail ? h("div", { className: "mkt_desc" }, props.detail) : null,
476
+ h("div", { className: "mkt_desc" }, "pnpm 已拦截下列安装脚本,尚未执行任何插件代码。允许后会先复核制品再运行;暂不允许时,重启 dsh 会自动撤回这次安装。"),
447
477
  pkgs.map(function (p, index) {
448
478
  var facts = [];
449
479
  if (typeof p.weeklyDownloads === "number") facts.push("周下载 " + p.weeklyDownloads.toLocaleString());
@@ -609,7 +639,6 @@ window.__ModuleLoader__.load({
609
639
  job.needsApproval && job.needsApproval.length > 0
610
640
  ? h(ApprovalRequest, {
611
641
  spec: job.spec,
612
- detail: job.detail,
613
642
  needsApproval: job.needsApproval,
614
643
  busy: props.approving === job.spec,
615
644
  onApprove: function (names) {
@@ -790,7 +819,7 @@ window.__ModuleLoader__.load({
790
819
  // Opaque one-shot approval tokens issued from needsApproval outcomes (spec -> token).
791
820
  // Cleared on success, unrelated failure, cancel, modal close, or spec change.
792
821
  // No boolean warning-consent map is kept.
793
- var sessionNonce = useRef(generateSessionNonce()).current;
822
+ var sessionNonce = useRef(readOrCreateBrowserSession()).current;
794
823
  var approvalTokensRef = useRef({});
795
824
  var restartControlRef = useRef({ mounted: true, ping: null });
796
825
  var clearRestartPing = useCallback(function () {
@@ -1046,9 +1075,7 @@ window.__ModuleLoader__.load({
1046
1075
  // 响应语义是「已调度交接」而不是「重启成功」:新宿主何时接管
1047
1076
  // 由下面的轮询判断。这里只把输出会出现在哪里告诉用户。
1048
1077
  scheduledLogPath = value && value.logPath ? String(value.logPath) : null;
1049
- setRestartNote(value && value.mode === "visible"
1050
- ? "重启已调度——dsh 正在新的控制台窗口中启动,窗口里 Ctrl+C 可停止;输出同时写入 " + scheduledLogPath
1051
- : "重启已调度,输出与日志:" + (scheduledLogPath || "<home>/guard/ 下"));
1078
+ setRestartNote("重启中,输出与日志:" + (scheduledLogPath || "<home>/guard/ 下"));
1052
1079
  var tries = 0;
1053
1080
  var ping = setInterval(function () {
1054
1081
  if (restartControlRef.current.mounted !== true) {
package/src/guard.js CHANGED
@@ -24,8 +24,10 @@ import {
24
24
  import { homedir, tmpdir } from "node:os";
25
25
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
26
26
  import { createRequire } from "node:module";
27
+ import { createHash } from "node:crypto";
27
28
  import { JSON_SCHEMA, Type, load } from "js-yaml";
28
29
  import { satisfies, validRange } from "semver";
30
+ import { stripTerminalControlSequences } from "./terminal.js";
29
31
 
30
32
  // Official patches (e.g. @deepseek-ai/dsh-base, dsh-web-app) mark raw JS
31
33
  // expressions with the scalar tag `!!js`. Construct the loader's own marker —
@@ -1450,6 +1452,84 @@ export function validateInstalledProfile(profileDir) {
1450
1452
  };
1451
1453
  }
1452
1454
 
1455
+ /** Stable identity for one installed-profile blocker across a transaction. */
1456
+ function staticBlockerFingerprint(entry) {
1457
+ const canonical = (value) => {
1458
+ if (Array.isArray(value)) return value.map(canonical);
1459
+ if (value !== null && typeof value === "object") {
1460
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
1461
+ }
1462
+ return value;
1463
+ };
1464
+ return createHash("sha256").update(JSON.stringify(canonical(entry))).digest("hex");
1465
+ }
1466
+
1467
+ function staticBlockerFingerprints(validation) {
1468
+ return validation.issues
1469
+ .filter((entry) => entry.severity === "block")
1470
+ .map(staticBlockerFingerprint);
1471
+ }
1472
+
1473
+ /**
1474
+ * Validate the live profile relative to the state captured before a pending
1475
+ * transaction. An unchanged blocker that was already present did not come
1476
+ * from this install/remove and must not make the transaction roll back. New
1477
+ * markers carry the baseline; old markers do not, and deliberately retain the
1478
+ * old fail-closed behaviour.
1479
+ */
1480
+ export function validatePendingProfile(profileDir, pending = readValidatedPendingSnapshot(profileDir)) {
1481
+ const validation = validateInstalledProfile(profileDir);
1482
+ if (!Array.isArray(pending?.baselineBlockers)) {
1483
+ return { ...validation, allIssues: validation.issues, preexistingIssues: [] };
1484
+ }
1485
+
1486
+ const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
1487
+ const remaining = new Map();
1488
+ for (const fingerprint of pending.baselineBlockers) {
1489
+ remaining.set(fingerprint, (remaining.get(fingerprint) ?? 0) + 1);
1490
+ }
1491
+ const issues = [];
1492
+ const preexistingIssues = [];
1493
+ for (const entry of validation.issues) {
1494
+ if (entry.severity !== "block") {
1495
+ issues.push(entry);
1496
+ continue;
1497
+ }
1498
+ // Updating the package that owns (or participates in) an old blocker must
1499
+ // prove that blocker was actually fixed. Only unrelated historical issues
1500
+ // receive baseline treatment.
1501
+ const candidateRelated = entry.package === candidateName
1502
+ || (Array.isArray(entry.conflictsWith) && entry.conflictsWith.includes(candidateName));
1503
+ if (candidateRelated) {
1504
+ issues.push(entry);
1505
+ continue;
1506
+ }
1507
+ const fingerprint = staticBlockerFingerprint(entry);
1508
+ const count = remaining.get(fingerprint) ?? 0;
1509
+ if (count > 0) {
1510
+ preexistingIssues.push(entry);
1511
+ remaining.set(fingerprint, count - 1);
1512
+ } else {
1513
+ issues.push(entry);
1514
+ }
1515
+ }
1516
+ const blockers = issues.filter((entry) => entry.severity === "block");
1517
+ const warnings = issues.filter((entry) => entry.severity === "warn");
1518
+ const verdict = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "safe";
1519
+ return {
1520
+ ok: blockers.length === 0,
1521
+ verdict,
1522
+ issues,
1523
+ allIssues: validation.issues,
1524
+ preexistingIssues,
1525
+ summary: blockers.length > 0
1526
+ ? `发现 ${blockers.length} 个本次新增阻断问题、${preexistingIssues.length} 个既有阻断问题、${warnings.length} 个警告`
1527
+ : preexistingIssues.length > 0
1528
+ ? `本次变更未新增阻断问题(保留 ${preexistingIssues.length} 个既有阻断问题)`
1529
+ : validation.summary,
1530
+ };
1531
+ }
1532
+
1453
1533
  /**
1454
1534
  * Remove leftover `node_modules` entries for packages that are no longer part
1455
1535
  * of the profile's declared dependencies. pnpm hoists each direct dependency
@@ -1723,7 +1803,7 @@ function spawnCapture(command, args, options, onOutput, { signal, treeKill = fal
1723
1803
  let child;
1724
1804
  const chunks = [];
1725
1805
  const push = (value) => {
1726
- const text = value.toString();
1806
+ const text = stripTerminalControlSequences(value);
1727
1807
  chunks.push(text);
1728
1808
  onOutput?.(text);
1729
1809
  };
@@ -1831,7 +1911,7 @@ export async function preflightInstall({ profileDir, spec, onOutput, signal }) {
1831
1911
  const result = await spawnCapture(
1832
1912
  plan.command,
1833
1913
  probeAddArgs(spec),
1834
- { cwd: probeDir, env: process.env, shell: plan.shell },
1914
+ { cwd: probeDir, env: pnpmGuardEnv(process.env), shell: plan.shell },
1835
1915
  onOutput,
1836
1916
  { signal, treeKill: plan.treeKill },
1837
1917
  );
@@ -1998,6 +2078,9 @@ function assertStoredTransactionMatches(pending) {
1998
2078
  if (pending.operation === "remove" && stored.metadata.packageName !== pending.metadata.packageName) {
1999
2079
  throw new Error("pending remove packageName does not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
2000
2080
  }
2081
+ if (JSON.stringify(stored.baselineBlockers) !== JSON.stringify(pending.baselineBlockers)) {
2082
+ throw new Error("pending marker baseline blockers do not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
2083
+ }
2001
2084
  }
2002
2085
 
2003
2086
  /**
@@ -2037,6 +2120,12 @@ function sanitizeSnapshot(marker, home) {
2037
2120
  if (!Array.isArray(marker.dependencies) || marker.dependencies.some((name) => typeof name !== "string" || !NPM_PACKAGE_NAME_RE.test(name))) {
2038
2121
  throw new Error("pending marker has missing or corrupt dependency metadata — refusing to act on it (left untouched for manual inspection)");
2039
2122
  }
2123
+ if (marker.baselineBlockers !== undefined && (
2124
+ !Array.isArray(marker.baselineBlockers)
2125
+ || marker.baselineBlockers.some((fingerprint) => typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint))
2126
+ )) {
2127
+ throw new Error("pending marker has corrupt baseline blocker metadata — refusing to act on it (left untouched for manual inspection)");
2128
+ }
2040
2129
  validatePendingTransaction(marker);
2041
2130
  const profileDir = resolve(String(marker.profileDir ?? ""));
2042
2131
  const profilesRoot = resolve(join(home, "profiles"));
@@ -2090,7 +2179,12 @@ export function createProfileSnapshot(profileDir, metadata = {}) {
2090
2179
  } catch {
2091
2180
  /* manifest unreadable — the rest of the snapshot still captures the bytes */
2092
2181
  }
2093
- const snapshot = { version: SNAPSHOT_VERSION, id, dir, profileDir, createdAt: Date.now(), files, dependencies, operation: normalizedMetadata.operation, metadata: normalizedMetadata };
2182
+ // Static validation after an install must judge what THIS transaction
2183
+ // changed. Without this baseline, an unrelated legacy plugin that already
2184
+ // shadowed host modules made every later update look guilty and forced the
2185
+ // marketplace itself back to its old version on restart.
2186
+ const baselineBlockers = staticBlockerFingerprints(validateInstalledProfile(profileDir));
2187
+ const snapshot = { version: SNAPSHOT_VERSION, id, dir, profileDir, createdAt: Date.now(), files, dependencies, baselineBlockers, operation: normalizedMetadata.operation, metadata: normalizedMetadata };
2094
2188
  writeFileSync(join(dir, "snapshot.json"), JSON.stringify(snapshot, undefined, 2) + "\n");
2095
2189
  return snapshot;
2096
2190
  }
@@ -2127,7 +2221,7 @@ export function markPendingSnapshot(snapshot, record = {}) {
2127
2221
  if (existsSync(markerPath)) {
2128
2222
  throw new Error(`profile already has a pending install marker at ${markerPath} — run \`dsh-plugin-guard guard recover\` (or let dsh startup recovery consume it) before installing again`);
2129
2223
  }
2130
- const pending = { ...snapshot, ...record, pendingAt: Date.now() };
2224
+ const pending = { ...snapshot, ...record, baselineBlockers: snapshot.baselineBlockers, pendingAt: Date.now() };
2131
2225
  // Reject producer bugs before persisting them. The read/recovery boundary
2132
2226
  // repeats this validation because the marker is attacker-controllable.
2133
2227
  validatePendingTransaction(pending);
@@ -2212,6 +2306,13 @@ function addedDependencyNames(pending, originalDependencies = pending?.dependenc
2212
2306
  export function pnpmGuardEnv(base = process.env) {
2213
2307
  return {
2214
2308
  ...base,
2309
+ // Browser jobs render plain text rather than a terminal. Prevent pnpm from
2310
+ // emitting colours at the source; output capture also strips CSI controls
2311
+ // defensively in case a child ignores these conventional switches.
2312
+ NO_COLOR: "1",
2313
+ FORCE_COLOR: "0",
2314
+ npm_config_color: "false",
2315
+ NPM_CONFIG_COLOR: "false",
2215
2316
  npm_config_auto_install_peers: "false",
2216
2317
  NPM_CONFIG_AUTO_INSTALL_PEERS: "false",
2217
2318
  };
@@ -2855,7 +2956,7 @@ export function recoverProfile(profileDir) {
2855
2956
  rebuild: rolled?.rebuild,
2856
2957
  };
2857
2958
  }
2858
- const validation = validateInstalledProfile(profileDir);
2959
+ const validation = validatePendingProfile(profileDir, pending);
2859
2960
  const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
2860
2961
  const removeValidation = isRemove
2861
2962
  ? validateRemoveCompletion(pending.profileDir, candidateName)
@@ -3958,6 +4059,58 @@ async function selfTest() {
3958
4059
  if (existsSync(snap.dir)) throw new Error("recoverProfile commit should delete the snapshot dir");
3959
4060
  }
3960
4061
 
4062
+ // A blocker that predates the transaction is still a real profile problem,
4063
+ // but it is not evidence that this update broke the profile. This is the
4064
+ // reported marketplace self-update failure: an unrelated legacy plugin
4065
+ // shadows host modules, so the new mall version used to be rolled back.
4066
+ {
4067
+ const p = join(root, "profiles", "preexisting-blocker");
4068
+ mkdirSync(join(p, "node_modules", "legacy"), { recursive: true });
4069
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
4070
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0", good: "1.0.0" } }));
4071
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
4072
+ writeFileSync(join(p, "node_modules", "legacy", "package.json"), JSON.stringify({
4073
+ name: "legacy", version: "1.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
4074
+ }));
4075
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
4076
+ if (validateInstalledProfile(p).issues.filter((entry) => entry.code === "host-module-shadow").length !== 1) {
4077
+ throw new Error("pre-existing blocker fixture prerequisite failed");
4078
+ }
4079
+ const snap = createProfileSnapshot(p, { fixture: true });
4080
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
4081
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0", good: "2.0.0" } }));
4082
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
4083
+ const relative = validatePendingProfile(p);
4084
+ if (!relative.ok || relative.preexistingIssues.length !== 1 || relative.issues.some((entry) => entry.severity === "block")) {
4085
+ throw new Error(`unchanged pre-existing blocker must not condemn this update: ${JSON.stringify(relative)}`);
4086
+ }
4087
+ const rec = recoverProfile(p);
4088
+ if (rec.action !== "committed") throw new Error(`unchanged pre-existing blocker should commit this update, got ${rec.action}`);
4089
+ if (readJson(join(p, "node_modules", "good", "package.json")).version !== "2.0.0") {
4090
+ throw new Error("committing past a pre-existing blocker must keep the updated candidate");
4091
+ }
4092
+ console.log("PASS pending 基线:既有宿主模块 blocker 不再回滚无关更新");
4093
+
4094
+ const candidateProfile = join(root, "profiles", "candidate-own-blocker");
4095
+ mkdirSync(join(candidateProfile, "node_modules", "legacy"), { recursive: true });
4096
+ writeFileSync(join(candidateProfile, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0" } }));
4097
+ writeFileSync(join(candidateProfile, "cordis.patch.yml"), "[]\n");
4098
+ writeFileSync(join(candidateProfile, "node_modules", "legacy", "package.json"), JSON.stringify({
4099
+ name: "legacy", version: "1.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
4100
+ }));
4101
+ const ownSnap = createProfileSnapshot(candidateProfile, { fixture: true });
4102
+ markPendingSnapshot(ownSnap, { spec: "legacy@2.0.0", preflight: { candidate: { name: "legacy", version: "2.0.0", kind: "plain" } } });
4103
+ writeFileSync(join(candidateProfile, "node_modules", "legacy", "package.json"), JSON.stringify({
4104
+ name: "legacy", version: "2.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
4105
+ }));
4106
+ const own = validatePendingProfile(candidateProfile);
4107
+ if (own.ok || !own.issues.some((entry) => entry.code === "host-module-shadow")) {
4108
+ throw new Error("an updated candidate must not inherit an exemption for its own old blocker");
4109
+ }
4110
+ commitPendingSnapshot(candidateProfile);
4111
+ console.log("PASS pending 基线:候选自身的既有 blocker 必须在更新中修复");
4112
+ }
4113
+
3961
4114
  // Approval-pause mark, part 1: a paused marker must NEVER commit on
3962
4115
  // recovery — not even when the static validation would pass (the version
3963
4116
  // sits there with its build scripts never approved; committing would drop
@@ -4602,6 +4755,21 @@ async function selfTest() {
4602
4755
  if (env.npm_config_auto_install_peers !== "false" || env.NPM_CONFIG_AUTO_INSTALL_PEERS !== "false") {
4603
4756
  throw new Error("pnpmGuardEnv must disable peer auto-install");
4604
4757
  }
4758
+ if (env.NO_COLOR !== "1" || env.FORCE_COLOR !== "0"
4759
+ || env.npm_config_color !== "false" || env.NPM_CONFIG_COLOR !== "false") {
4760
+ throw new Error("pnpmGuardEnv must disable terminal colours for browser job logs");
4761
+ }
4762
+
4763
+ let streamed = "";
4764
+ const coloured = await spawnCapture(
4765
+ process.execPath,
4766
+ ["-e", "process.stdout.write('\\u001b[96mprobe ok\\u001b[39m\\n')"],
4767
+ { env: process.env, shell: false },
4768
+ (text) => { streamed += text; },
4769
+ );
4770
+ if (coloured.exitCode !== 0 || coloured.output !== "probe ok\n" || streamed !== "probe ok\n") {
4771
+ throw new Error(`spawnCapture must strip terminal controls from captured and streamed logs: ${JSON.stringify({ coloured, streamed })}`);
4772
+ }
4605
4773
  }
4606
4774
 
4607
4775
  // candidateRestoredCompatible: version vs the restored dependency spec.
@@ -5279,4 +5447,4 @@ if (process.argv[1]?.endsWith("guard.js") && process.argv.includes("--self-test"
5279
5447
  console.error(`FAIL ${error.stack ?? error.message}`);
5280
5448
  process.exitCode = 1;
5281
5449
  });
5282
- }
5450
+ }
package/src/index.js CHANGED
@@ -1026,13 +1026,12 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
1026
1026
 
1027
1027
  const nodePath = process.execPath;
1028
1028
  const originalDshArgs = process.argv.slice(2);
1029
- // A restart is always requested from a page that is open and that reconnects
1030
- // to the successor on its own, so dsh's start-up browser handoff would only
1031
- // add a second window onto the one already showing the result. Suppressing it
1032
- // is safe HERE and nowhere else: this route is gated on a live browser
1033
- // session. If the flag is already in the original argv, leave it alone.
1034
- const suppressOpen = !originalDshArgs.includes("--no-open");
1035
- const dshArgs = suppressOpen ? [...originalDshArgs, "--no-open"] : [...originalDshArgs];
1029
+ // Restart the exact command the user started. `--no-open` belongs to newer
1030
+ // Web profiles, not to the stable dsh launcher contract; adding it here made
1031
+ // older hosts reject the successor with "unknown option '--no-open'" after
1032
+ // the outgoing process had already exited. A duplicate browser tab is less
1033
+ // harmful than inventing an argv capability the running host never proved.
1034
+ const dshArgs = [...originalDshArgs];
1036
1035
  // The outgoing host names itself so `guard launch` can wait for it to be
1037
1036
  // gone before binding the port — see --await-exit in cli.js.
1038
1037
  const args = [cliPath, "guard", "launch", "--profile", name, "--await-exit", String(process.pid), "--", nodePath, dshEntry, ...dshArgs];
@@ -1045,7 +1044,6 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
1045
1044
  dshEntry,
1046
1045
  dshArgs,
1047
1046
  profile: name,
1048
- suppressedBrowserOpen: suppressOpen,
1049
1047
  awaitExitPid: process.pid,
1050
1048
  };
1051
1049
  }
@@ -3219,6 +3217,98 @@ export async function runSelfTests() {
3219
3217
  check("tracker 复制 producer 签发的 approvalToken(同 session 可见)",
3220
3218
  snapSameSession.approvalToken === trackerTok);
3221
3219
 
3220
+ // 异 session 即使拿到 token 字符串也消费不掉,且归属失败的尝试不许把
3221
+ // 正主的 token 烧掉(报 session mismatch,不是 already consumed)。
3222
+ // jobs 镜像里带着 token,复制标签页能拿到那串字符——挡住它的正是归属。
3223
+ const wrongOwnerConsume = consumeApprovalToken({
3224
+ token: trackerTok,
3225
+ profile: "web",
3226
+ profileDir,
3227
+ spec: "foo-script",
3228
+ preflightReport: cleanPreflightReport,
3229
+ surface: "browser",
3230
+ owner: "session-beta",
3231
+ });
3232
+ check("异 session 即使拿到 token 字符串也消费失败(session mismatch)",
3233
+ wrongOwnerConsume.valid === false && /session mismatch/.test(wrongOwnerConsume.reason),
3234
+ `reason=${wrongOwnerConsume.reason}`);
3235
+ check("归属失败的尝试没有烧掉 token(正主仍可取回)",
3236
+ sessionTracker.get(sessionJobId, "session-alpha").snapshot.approvalToken === trackerTok);
3237
+
3238
+ // ── 前端 session 身份(client.js 的真实源码,跑在受控假环境里)────────
3239
+ //
3240
+ // 这段测的是浏览器那半边:nonce 怎么生成、什么时候复用、什么时候必须
3241
+ // 换新。它从 src/client.js 里把函数定义原样抠出来执行——不是在这里重写
3242
+ // 一份等价逻辑,否则改坏了 client.js 测试照样绿。
3243
+ //
3244
+ // 钉住的四件事,每一件都对应一个真实故障:
3245
+ // 1. 重挂载复用 → 不复用就是 initial install 死锁(本次修的 bug);
3246
+ // 2. 新 window 必换新 → 换不了就是跨 tab 冒用别人的审批身份;
3247
+ // 3. 非法值必换新 → 别的脚本写坏了不能就这么发给后端;
3248
+ // 4. 全程不碰任何存储 → sessionStorage 会随「复制标签页」被复制,
3249
+ // 而 jobs 镜像里就带着 token,一起复制过去等于连身份带凭证都送出。
3250
+ {
3251
+ const clientSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "client.js"), "utf8");
3252
+ // 从 `function name(` 起按花括号配平截取,拿到完整定义。
3253
+ const extractFunction = (name) => {
3254
+ const start = clientSource.indexOf(`function ${name}(`);
3255
+ if (start === -1) throw new Error(`client.js 里找不到 function ${name}`);
3256
+ let depth = 0;
3257
+ let seenBrace = false;
3258
+ for (let i = start; i < clientSource.length; i++) {
3259
+ const ch = clientSource[i];
3260
+ if (ch === "{") { depth++; seenBrace = true; } else if (ch === "}") depth--;
3261
+ if (seenBrace && depth === 0) return clientSource.slice(start, i + 1);
3262
+ }
3263
+ throw new Error(`function ${name} 的花括号不配平`);
3264
+ };
3265
+ const reLine = clientSource.split("\n").find((line) => line.includes("var BROWSER_SESSION_RE ="));
3266
+ const propLine = clientSource.split("\n").find((line) => line.includes("var SESSION_NONCE_PROP ="));
3267
+ if (reLine === undefined || propLine === undefined) throw new Error("client.js 里找不到 session 常量定义");
3268
+ const snippet = [
3269
+ reLine.trim(),
3270
+ propLine.trim(),
3271
+ extractFunction("generateSessionNonce"),
3272
+ extractFunction("readOrCreateBrowserSession"),
3273
+ "return { readOrCreateBrowserSession: readOrCreateBrowserSession, SESSION_NONCE_PROP: SESSION_NONCE_PROP, BROWSER_SESSION_RE: BROWSER_SESSION_RE };",
3274
+ ].join("\n");
3275
+ // window 传 undefined:函数只该碰传进来的 scope,碰全局 window 就 ReferenceError。
3276
+ const front = new Function("window", snippet)(undefined);
3277
+
3278
+ // 假标签页:带一个会记账的 sessionStorage,用来证明它一次都没被碰过。
3279
+ let storageTouches = 0;
3280
+ const makeTab = () => ({
3281
+ sessionStorage: {
3282
+ getItem: () => { storageTouches++; return null; },
3283
+ setItem: () => { storageTouches++; },
3284
+ },
3285
+ });
3286
+
3287
+ const tabA = makeTab();
3288
+ const first = front.readOrCreateBrowserSession(tabA);
3289
+ check("前端 session:首次挂载生成合法 nonce 并挂在 window 上",
3290
+ front.BROWSER_SESSION_RE.test(first) && tabA[front.SESSION_NONCE_PROP] === first, `nonce=${first}`);
3291
+
3292
+ const remounted = front.readOrCreateBrowserSession(tabA);
3293
+ check("前端 session:同一页面重挂载复用同一个 nonce(token 因此仍归本人)",
3294
+ remounted === first);
3295
+
3296
+ const tabB = makeTab();
3297
+ const otherTab = front.readOrCreateBrowserSession(tabB);
3298
+ check("前端 session:新 window(新 tab / 复制标签页)拿到的是另一个 nonce",
3299
+ front.BROWSER_SESSION_RE.test(otherTab) && otherTab !== first);
3300
+
3301
+ const tampered = makeTab();
3302
+ tampered[front.SESSION_NONCE_PROP] = "sess_not-a-valid-nonce";
3303
+ const replaced = front.readOrCreateBrowserSession(tampered);
3304
+ check("前端 session:window 上的值格式非法 → 丢弃并重新生成",
3305
+ front.BROWSER_SESSION_RE.test(replaced) && replaced !== "sess_not-a-valid-nonce"
3306
+ && tampered[front.SESSION_NONCE_PROP] === replaced);
3307
+
3308
+ check("前端 session:全程不读写任何存储(sessionStorage 会随复制标签页一起复制,jobs 镜像里带着 token)",
3309
+ storageTouches === 0, `touches=${storageTouches}`);
3310
+ }
3311
+
3222
3312
  let cancelRefused = false;
3223
3313
  try {
3224
3314
  sessionTracker.cancel(sessionJobId, "session-beta");
@@ -3350,15 +3440,15 @@ export async function runSelfTests() {
3350
3440
  const dshArgs = plan.args.slice(dashDash + 3); // -- node <dshEntry> …
3351
3441
  check("重启带 --await-exit 且是本进程 pid", plan.args[plan.args.indexOf("--await-exit") + 1] === String(process.pid) && plan.awaitExitPid === process.pid);
3352
3442
  check("--await-exit 排在 `--` 之前(是 guard 的参数,不是 dsh 的)", plan.args.indexOf("--await-exit") < dashDash);
3353
- check("重启给 dsh --no-open", dshArgs.includes("--no-open") && plan.suppressedBrowserOpen === true);
3354
- check("原始 dsh 参数原样保留", dshArgs.slice(0, 2).join(" ") === "--profile web");
3443
+ check("重启不注入宿主版本相关参数", dshArgs.join(" ") === "--profile web" && !dshArgs.includes("--no-open"));
3444
+ check("原始 dsh 参数原样保留", dshArgs.join(" ") === "--profile web");
3355
3445
  check("plan 附带可见模式所需的 dshArgs", plan.dshArgs.join(" ") === dshArgs.join(" "));
3356
3446
 
3357
- // 用户自己已经写了 --no-open 时不重复追加。
3447
+ // 用户自己传给宿主的参数仍逐字保留。
3358
3448
  process.argv = [process.execPath, "/x/bin.js", "--profile", "web", "--no-open"];
3359
3449
  const already = resolveRestartLaunchPlan({ profile: "web", config: { allowRestart: true } });
3360
3450
  const alreadyArgs = already.ok ? already.args.slice(already.args.indexOf("--") + 3) : [];
3361
- check("已有 --no-open 则不重复追加", already.ok && alreadyArgs.filter((a) => a === "--no-open").length === 1 && already.suppressedBrowserOpen === false);
3451
+ check("用户原有 --no-open 原样保留", already.ok && alreadyArgs.filter((a) => a === "--no-open").length === 1);
3362
3452
  } else {
3363
3453
  // 裸检出里解析不到官方 dsh 入口,plan 只能 fail——说清楚,别假装验过。
3364
3454
  console.log(` SKIP 重启 argv fixture(${plan.error})`);
@@ -3546,7 +3636,7 @@ export async function runSelfTests() {
3546
3636
  readyFile: join(fileRoot, "r1.json"),
3547
3637
  cwd: "C:/w",
3548
3638
  command: "C:/node/node.exe",
3549
- args: ["C:/dsh/index.js", "web", "--no-open"],
3639
+ args: ["C:/dsh/index.js", "web"],
3550
3640
  };
3551
3641
  check("plan payload 校验通过", validateRestartPlanPayload(basePlan).ok === true);
3552
3642
  for (const [override, needle] of [
@@ -3744,7 +3834,7 @@ export async function runSelfTests() {
3744
3834
  nodePath: process.execPath,
3745
3835
  cliPath: join(visibleRoot, "cli.js"),
3746
3836
  dshEntry: join(visibleRoot, "dsh-entry.js"),
3747
- dshArgs: ["--profile", "web", "--no-open"],
3837
+ dshArgs: ["--profile", "web"],
3748
3838
  profile: "web",
3749
3839
  awaitExitPid: 4242,
3750
3840
  };