@1e0zj/dsh-plugin-mall 0.4.14 → 0.4.16

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.14",
3
+ "version": "0.4.16",
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
@@ -128,10 +128,66 @@ window.__ModuleLoader__.load({
128
128
  return String(e && e.message ? e.message : e);
129
129
  }
130
130
 
131
+ // ── 更新按钮的防连点判据 ────────────────────────────────────────────────
132
+ //
133
+ // 纯函数,不碰 React;index.js 的 --self-test 把这一整段从本文件里原样抠
134
+ // 出来跑(同下面 session nonce 那段的做法),所以改坏了这里测试会红。
135
+ // 往段里加函数会自动被测到,把函数搬出去则当场 ReferenceError。
136
+
131
137
  function isJobActive(status) {
132
138
  return status === "running" || status === "stopping";
133
139
  }
134
140
 
141
+ // spec → npm 包名。**按包名、不按 spec** 是这段的关键:同一条更新链在不同
142
+ // 阶段的 spec 并不一样(点的是 foo@1.2.3,服务端防抢注解析后 track 存下的
143
+ // 可能是别的形态),按 spec 盯就会在解析之后丢失目标。
144
+ // 与服务端 npmNameOf(github.js)同一套形状;非 npm 形态返回 null。
145
+ function specPackageName(spec) {
146
+ if (typeof spec !== "string" || spec.length === 0) return null;
147
+ if (spec.charAt(0) === "@") {
148
+ var scoped = /^(@[^/@\s]+\/[^/@\s]+?)(?:@[^/\s]+)?$/.exec(spec);
149
+ return scoped === null ? null : scoped[1];
150
+ }
151
+ var bare = /^([^@/\s:]+)(?:@[^/\s]+)?$/.exec(spec);
152
+ return bare === null ? null : bare[1];
153
+ }
154
+
155
+ // 这个包上有没有一条没了结的更新链。三段接力,中间不能断:
156
+ //
157
+ // updating 点击 → 预检 job 建立。这段 jobs 里还什么都没有,只有同步
158
+ // 的 ref 挡得住同一拍里的第二次点击。
159
+ // installing 预检落终态 → 安装 job 建立。预检那条已经不 active 了,
160
+ // 安装那条还没 track 上,中间这一拍靠它顶住。
161
+ // jobs job 全程。**RPC 返回只意味着 job 建好了,pnpm 还没开始跑**
162
+ // ——锁必须持续到 job 落终态,不是到 RPC 返回。
163
+ // needsApproval 的 status 是 failed,但那是等用户决定的暂停,
164
+ // 事务没结束,算未了结。
165
+ // card 预检出了风险卡片,停在那里等用户读完再决定。这期间任务是
166
+ // 终态,但决定还没做,再起一条链就是两条链抢同一个事务。
167
+ //
168
+ // sources 里递进来的必须是 ref.current 这类当下的值:判定发生在点击那一
169
+ // 刻,上一次渲染的快照没有意义。
170
+ function updateChainBusy(sources, name) {
171
+ if (name === null || name === undefined) return false;
172
+ if ((sources.updating || {})[name] === true) return true;
173
+ var installing = sources.installing || {};
174
+ for (var spec in installing) {
175
+ if (installing[spec] === true && specPackageName(spec) === name) return true;
176
+ }
177
+ var card = sources.card;
178
+ if (card && specPackageName(card.spec) === name) return true;
179
+ var jobs = sources.jobs || {};
180
+ for (var id in jobs) {
181
+ var job = jobs[id];
182
+ if (!job || typeof job.spec !== "string") continue;
183
+ if (specPackageName(job.spec) !== name) continue;
184
+ if (isJobActive(job.status)) return true;
185
+ if (Array.isArray(job.needsApproval) && job.needsApproval.length > 0) return true;
186
+ }
187
+ return false;
188
+ }
189
+ // ── 更新按钮的防连点判据结束 ────────────────────────────────────────────
190
+
135
191
  function kindLabel(kind) {
136
192
  if (kind === "bundle") return "宿主插件层";
137
193
  if (kind === "client") return "浏览器UI插件";
@@ -157,6 +213,36 @@ window.__ModuleLoader__.load({
157
213
  throw new Error("Secure browser randomness is unavailable; plugin marketplace actions are disabled");
158
214
  }
159
215
 
216
+ // 与 src/index.js 的 BROWSER_SESSION_RE 保持一致:window 上的值同样是
217
+ // 不可信输入(别的脚本能写),读出来必须重新过格式校验。
218
+ 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;
219
+ var SESSION_NONCE_PROP = "__dshPluginMallSession";
220
+ // nonce 挂在 window 上(进程内,不落任何存储)。
221
+ //
222
+ // 要解决的:更新插件市场自身会替换正在运行的市场包,组装树随之重放、
223
+ // 整个市场 UI 重挂载——useRef 里的 nonce 归零,而审批 token 只发给签发
224
+ // 时的同一个 session,于是 needsApproval 暂停的任务在重挂载后永远拿不回
225
+ // token,用户点「允许」必报 initial install。重挂载是 React 层的(页面
226
+ // 没有重新加载),window 对象自始至终是同一个,进程内值正好覆盖它。
227
+ //
228
+ // 不用 sessionStorage:它在 window.open(同源) 和浏览器「复制标签页」时
229
+ // 会被复制给新标签页。jobs 镜像里本就带着 approvalToken,nonce 再一起
230
+ // 复制过去,新标签页就同时拿到了 token 和它的归属身份——后端会认成同一
231
+ // 个 session,跨 tab 隔离(47db0c4)就此失效。window 上的值不随标签页
232
+ // 复制,新标签页天然是新身份。
233
+ //
234
+ // 代价:页面刷新(F5)后 nonce 重新生成,刷新前签发的 token 作废。这是
235
+ // 对的边界——刷新是用户主动重来,暂停的任务重启 dsh 即可撤回;而重挂载
236
+ // 是用户没做任何事的情况下被系统掀了桌子,那才是必须兜住的。
237
+ function readOrCreateBrowserSession(host) {
238
+ var scope = host || window;
239
+ var current = scope[SESSION_NONCE_PROP];
240
+ if (typeof current === "string" && BROWSER_SESSION_RE.test(current)) return current;
241
+ var nonce = generateSessionNonce();
242
+ scope[SESSION_NONCE_PROP] = nonce;
243
+ return nonce;
244
+ }
245
+
160
246
  // ── job polling ─────────────────────────────────────────────────────────
161
247
  // onPreflightSettled:预检 job(kind=dsh-plugin-preflight)落定时回调,
162
248
  // 携带 (spec, report)。safe 由调用方直接续装;有风险由调用方出内联卡片。
@@ -314,7 +400,8 @@ window.__ModuleLoader__.load({
314
400
  }
315
401
  commit(next);
316
402
  }, [commit]);
317
- return { jobs: jobs, track: track, drop: drop, setStatus: setStatus, restore: restore };
403
+ // jobsRef 一并交出去:防连点的判定读的是当下的值,不是渲染快照。
404
+ return { jobs: jobs, jobsRef: jobsRef, track: track, drop: drop, setStatus: setStatus, restore: restore };
318
405
  }
319
406
 
320
407
  // ── plugin verification badge ───────────────────────────────────────────
@@ -685,6 +772,7 @@ window.__ModuleLoader__.load({
685
772
  var togglable = entry !== undefined && dep.name !== MARKET_PACKAGE;
686
773
  var enabled = entry === undefined ? true : entry.enabled !== false;
687
774
  var toggling = (props.toggling || {})[dep.name] === true;
775
+ var updating = props.isUpdating(dep.name);
688
776
  return h("div", { key: dep.name, className: "mkt_depRow" },
689
777
  h("span", { className: "mkt_desc" + (enabled ? "" : " mkt_depOff") }, dep.name + "@" + dep.version),
690
778
  h("span", { className: "mkt_depActions" },
@@ -698,8 +786,11 @@ window.__ModuleLoader__.load({
698
786
  }, h("span", { className: "mkt_switchKnob" })) : null,
699
787
  upd && upd.hasUpdate ? h("button", {
700
788
  className: "mkt_btn mkt_btnSm",
701
- onClick: function () { props.onInstallSpec(dep.name + "@" + upd.latest); },
702
- }, "更新至 " + upd.latest) : null,
789
+ // doUpdate 里的守卫读同一个判据,两边不会漂移——不一致
790
+ // 就会出现「按钮看着能点,点了什么也不发生」。
791
+ disabled: updating,
792
+ onClick: function () { props.onUpdate(dep.name, dep.name + "@" + upd.latest); },
793
+ }, updating ? "更新中…" : "更新至 " + upd.latest) : null,
703
794
  h("button", {
704
795
  className: "mkt_btn mkt_btnDanger mkt_btnSm",
705
796
  disabled: busy,
@@ -786,10 +877,23 @@ window.__ModuleLoader__.load({
786
877
  var preflight = _preflight[0];
787
878
  var setPreflight = _preflight[1];
788
879
 
880
+ // 更新按钮防连点用的三份当下值(见 updateChainBusy)。ref 而不是 state:
881
+ // 判定发生在点击那一刻,而 setState 到重渲染之间还有一拍能点。ref 不触发
882
+ // 渲染,所以每次改动配一次 bumpBusy,让按钮当场变样。
883
+ var updatingRef = useRef({}); // 包名 -> true,点击到预检 job 建立之间
884
+ var installingRef = useRef({}); // spec -> true,安装 RPC 在途
885
+ var preflightRef = useRef(null);
886
+ preflightRef.current = preflight; // 渲染期同步,同 resultsRef 的写法
887
+ var _busyTick = useState(0);
888
+ var setBusyTick = _busyTick[1];
889
+ var bumpBusy = useCallback(function () {
890
+ setBusyTick(function (n) { return n + 1; });
891
+ }, []);
892
+
789
893
  // Opaque one-shot approval tokens issued from needsApproval outcomes (spec -> token).
790
894
  // Cleared on success, unrelated failure, cancel, modal close, or spec change.
791
895
  // No boolean warning-consent map is kept.
792
- var sessionNonce = useRef(generateSessionNonce()).current;
896
+ var sessionNonce = useRef(readOrCreateBrowserSession()).current;
793
897
  var approvalTokensRef = useRef({});
794
898
  var restartControlRef = useRef({ mounted: true, ping: null });
795
899
  var clearRestartPing = useCallback(function () {
@@ -953,6 +1057,17 @@ window.__ModuleLoader__.load({
953
1057
  var polling = useJobPolling(call, onJobSettled, onApprovalToken, onPreflightSettled);
954
1058
  var track = polling.track;
955
1059
  var jobs = polling.jobs;
1060
+ var jobsRef = polling.jobsRef;
1061
+
1062
+ // 按钮的 disabled 和 handler 里的守卫都读这一个函数,两边不会漂移。
1063
+ var updateBusy = useCallback(function (packageName) {
1064
+ return updateChainBusy({
1065
+ updating: updatingRef.current,
1066
+ installing: installingRef.current,
1067
+ card: preflightRef.current,
1068
+ jobs: jobsRef.current,
1069
+ }, packageName);
1070
+ }, [jobsRef]);
956
1071
  var dropJob = polling.drop;
957
1072
  var setJobStatus = polling.setStatus;
958
1073
 
@@ -970,6 +1085,10 @@ window.__ModuleLoader__.load({
970
1085
  }, []);
971
1086
 
972
1087
  var doRawInstall = useCallback(function (spec, extra, carryFromId) {
1088
+ // ref 与 state 同时置位:state 负责触发渲染,ref 才是判定读的那一份
1089
+ // (见 updateChainBusy)。这一段是预检 job 已落终态、安装 job 还没
1090
+ // track 上的空档,中间断一拍更新按钮就会复活。
1091
+ installingRef.current[spec] = true;
973
1092
  setInstalling(function (prev) {
974
1093
  var next = Object.assign({}, prev, { [spec]: true });
975
1094
  return next;
@@ -982,6 +1101,7 @@ window.__ModuleLoader__.load({
982
1101
  delete approvalTokensRef.current[spec];
983
1102
  reportError("tasks", e);
984
1103
  }).finally(function () {
1104
+ delete installingRef.current[spec];
985
1105
  setInstalling(function (prev) {
986
1106
  var next = Object.assign({}, prev);
987
1107
  delete next[spec];
@@ -1018,13 +1138,17 @@ window.__ModuleLoader__.load({
1018
1138
 
1019
1139
  // 点安装 = 立刻起一个预检 job,面板马上有东西看、日志实时流。
1020
1140
  // 结论由轮询经 onPreflightSettled 交回上面的 handler,这里不等待。
1021
- var preflightAndInstall = useCallback(function (spec) {
1141
+ // onStarted:RPC 落定(不论成败)时回调一次。更新按钮用它把「点击
1142
+ // job 建立」这一段的锁交给 job,市场卡片不传,行为一字未变。
1143
+ var preflightAndInstall = useCallback(function (spec, onStarted) {
1022
1144
  delete approvalTokensRef.current[spec];
1023
1145
  setError(null);
1024
1146
  call("preflight", { spec: spec }).then(function (value) {
1025
1147
  track(value.jobId, value.spec || spec);
1026
1148
  }).catch(function (e) {
1027
1149
  reportError("tasks", e);
1150
+ }).finally(function () {
1151
+ if (typeof onStarted === "function") onStarted();
1028
1152
  });
1029
1153
  }, [call, reportError, track]);
1030
1154
 
@@ -1032,6 +1156,26 @@ window.__ModuleLoader__.load({
1032
1156
  preflightAndInstall("github:" + repo);
1033
1157
  }, [preflightAndInstall]);
1034
1158
 
1159
+ // 已装列表的「更新至 X」。此前它是那一行里唯一没有 disabled 的按钮,
1160
+ // 而起链要走联网的 registry 解析,那几秒里 jobs 还是空的、按钮还是可点
1161
+ // 的——连点几下就是几条预检+安装链,profile 事务队列会把它们排队串行
1162
+ // 跑完,同一个版本被反复装好几遍。
1163
+ //
1164
+ // 守卫按包名(updateChainBusy 的注释说明了为什么不能按 spec),同步的
1165
+ // ref 挡住同一拍里的第二次点击;RPC 返回只解开 updating 那一段,之后由
1166
+ // 对应的 job 接管,锁一直持续到 job 落终态。
1167
+ var doUpdate = useCallback(function (packageName, spec) {
1168
+ if (updateBusy(packageName)) return;
1169
+ updatingRef.current[packageName] = true;
1170
+ bumpBusy();
1171
+ preflightAndInstall(spec, function () {
1172
+ // preflight RPC 已返回:jobs 里已经有条目接手,这里才允许松手。
1173
+ // 顺序反过来会闪出一拍「谁都不认为它在忙」的可点窗口。
1174
+ delete updatingRef.current[packageName];
1175
+ bumpBusy();
1176
+ });
1177
+ }, [preflightAndInstall, updateBusy, bumpBusy]);
1178
+
1035
1179
  var doRestart = useCallback(function () {
1036
1180
  clearRestartPing();
1037
1181
  setRestarting(true);
@@ -1045,9 +1189,7 @@ window.__ModuleLoader__.load({
1045
1189
  // 响应语义是「已调度交接」而不是「重启成功」:新宿主何时接管
1046
1190
  // 由下面的轮询判断。这里只把输出会出现在哪里告诉用户。
1047
1191
  scheduledLogPath = value && value.logPath ? String(value.logPath) : null;
1048
- setRestartNote(value && value.mode === "visible"
1049
- ? "重启已调度——dsh 正在新的控制台窗口中启动,窗口里 Ctrl+C 可停止;输出同时写入 " + scheduledLogPath
1050
- : "重启已调度,输出与日志:" + (scheduledLogPath || "<home>/guard/ 下"));
1192
+ setRestartNote("重启中,输出与日志:" + (scheduledLogPath || "<home>/guard/ 下"));
1051
1193
  var tries = 0;
1052
1194
  var ping = setInterval(function () {
1053
1195
  if (restartControlRef.current.mounted !== true) {
@@ -1157,7 +1299,8 @@ window.__ModuleLoader__.load({
1157
1299
  toggling: toggling,
1158
1300
  onToggle: doToggle,
1159
1301
  onUninstall: doUninstall,
1160
- onInstallSpec: preflightAndInstall,
1302
+ onUpdate: doUpdate,
1303
+ isUpdating: updateBusy,
1161
1304
  error: error && error.scope === "installed" ? error.message : null,
1162
1305
  }),
1163
1306
  h(JobsPanel, {
package/src/index.js CHANGED
@@ -3217,6 +3217,154 @@ export async function runSelfTests() {
3217
3217
  check("tracker 复制 producer 签发的 approvalToken(同 session 可见)",
3218
3218
  snapSameSession.approvalToken === trackerTok);
3219
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
+
3312
+ // 已装列表「更新至 X」的防连点判据。同样从 src/client.js 原样抠出来跑
3313
+ // ——它在浏览器里是 React 闭包,测不到;提成纯函数就是为了能在这里钉住。
3314
+ //
3315
+ // 钉住的两件事,各对应一个真实的洞:
3316
+ // 1. 按包名判定 —— 按 spec 判定会在服务端解析之后丢失目标;
3317
+ // 2. 锁到 job 终态 —— 只锁 RPC 往返的话,jobId 一返回按钮就复活,而那
3318
+ // 时 pnpm 还一步都没跑。
3319
+ {
3320
+ const clientSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "client.js"), "utf8");
3321
+ const begin = clientSource.indexOf("// ── 更新按钮的防连点判据 ─");
3322
+ const end = clientSource.indexOf("// ── 更新按钮的防连点判据结束 ─");
3323
+ if (begin === -1 || end === -1 || end < begin) throw new Error("client.js 里找不到「更新按钮的防连点判据」整段");
3324
+ // 整段抠,不是逐个函数挑:依赖(isJobActive、specPackageName)就在段内,
3325
+ // 往段里加函数会自动被测到,搬出段外则当场 ReferenceError。
3326
+ const front = new Function([
3327
+ clientSource.slice(begin, end),
3328
+ "return { specPackageName, updateChainBusy };",
3329
+ ].join("\n"))();
3330
+
3331
+ const busy = (patch, name) => front.updateChainBusy(
3332
+ Object.assign({ updating: {}, installing: {}, card: null, jobs: {} }, patch), name);
3333
+ const running = (spec) => ({ spec, status: "running" });
3334
+
3335
+ check("更新防连点:裸包名折算", front.specPackageName("foo") === "foo");
3336
+ check("更新防连点:带版本折算", front.specPackageName("foo@1.2.3") === "foo");
3337
+ check("更新防连点:scoped 折算", front.specPackageName("@a/b@9.9.9") === "@a/b");
3338
+ check("更新防连点:非 npm 形态返回 null", front.specPackageName("github:o/r") === null);
3339
+
3340
+ check("更新防连点:点击到 job 建立之间靠同步 ref 挡住(第二次点击)",
3341
+ busy({ updating: { foo: true } }, "foo") === true);
3342
+ check("更新防连点:预检 job 在跑时锁着",
3343
+ busy({ jobs: { a: running("foo@1.2.3") } }, "foo") === true);
3344
+ check("更新防连点:预检落终态、安装 RPC 在途的空档也锁着",
3345
+ busy({ installing: { "foo@1.2.3": true }, jobs: { a: { spec: "foo@1.2.3", status: "completed" } } }, "foo") === true);
3346
+ check("更新防连点:安装 job 在跑时锁着(RPC 早返回了,pnpm 才刚开始)",
3347
+ busy({ jobs: { a: running("foo@1.2.3") } }, "foo") === true);
3348
+ check("更新防连点:stopping 也算在跑",
3349
+ busy({ jobs: { a: { spec: "foo@1.2.3", status: "stopping" } } }, "foo") === true);
3350
+ check("更新防连点:停在批准卡片上(failed + needsApproval)算未了结",
3351
+ busy({ jobs: { a: { spec: "foo@1.2.3", status: "failed", needsApproval: [{ name: "x" }] } } }, "foo") === true);
3352
+ check("更新防连点:停在风险卡片上时锁着(决定还没做,再起一条就是两条链)",
3353
+ busy({ card: { spec: "foo@1.2.3" } }, "foo") === true);
3354
+
3355
+ check("更新防连点:job 落终态后解锁,用户可以重试",
3356
+ busy({ jobs: { a: { spec: "foo@1.2.3", status: "failed" } } }, "foo") === false);
3357
+ check("更新防连点:completed 后解锁",
3358
+ busy({ jobs: { a: { spec: "foo@1.2.3", status: "completed" } } }, "foo") === false);
3359
+ check("更新防连点:别的包在跑不锁本行",
3360
+ busy({ jobs: { a: running("bar@1.0.0") } }, "foo") === false);
3361
+ check("更新防连点:风险卡片停在别的包上不锁本行",
3362
+ busy({ card: { spec: "bar@1.0.0" } }, "foo") === false);
3363
+ check("更新防连点:什么都没在途 → 不锁", busy({}, "foo") === false);
3364
+ check("更新防连点:包名为 null 时不误判",
3365
+ busy({ jobs: { a: running("foo@1.2.3") } }, null) === false);
3366
+ }
3367
+
3220
3368
  let cancelRefused = false;
3221
3369
  try {
3222
3370
  sessionTracker.cancel(sessionJobId, "session-beta");
package/src/installer.js CHANGED
@@ -766,6 +766,169 @@ function splitBuildSelector(key) {
766
766
  return NPM_NAME_RE.test(name) ? { name, spec: text.slice(suffix.index + 1) } : undefined;
767
767
  }
768
768
 
769
+ /**
770
+ * Workspace keys pnpm writes durably on its own during an install. Everything
771
+ * else the transaction touches in pnpm-workspace.yaml is our temporary
772
+ * `allowBuilds` neutralization, which the post-install restore is supposed to
773
+ * wipe.
774
+ */
775
+ const PERSISTENT_WORKSPACE_KEYS = ["minimumReleaseAgeExclude"];
776
+
777
+ /**
778
+ * Match ONE top-level `key:` line, in every legal YAML spelling of the key:
779
+ * bare, single-quoted, or double-quoted. Key-locating that only knows the
780
+ * bare form misreads a quoted original as "key absent" and appends a
781
+ * duplicate mapping key — silently corrupting the file.
782
+ */
783
+ function topLevelKeyRegex(key) {
784
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
785
+ return new RegExp(`^(?:${escaped}|'${escaped}'|"${escaped}")\\s*:`);
786
+ }
787
+
788
+ /**
789
+ * Splice one top-level key's value into a workspace yaml, textually.
790
+ *
791
+ * The value is re-serialized, but the surrounding file keeps its bytes:
792
+ * comments, key order, and unrelated keys are the user's, and the restore
793
+ * must not cost them their formatting (same discipline as the patch-file
794
+ * edits).
795
+ */
796
+ function spliceWorkspaceKey(content, key, value) {
797
+ const block = dump({ [key]: value }, { lineWidth: -1, noRefs: true, sortKeys: false }).replace(/\n+$/, "");
798
+ const keyLine = topLevelKeyRegex(key);
799
+ const lines = content.split("\n");
800
+ const start = lines.findIndex((line) => keyLine.test(line));
801
+ if (start === -1) {
802
+ return `${content.replace(/\n*$/, "\n")}${block}\n`;
803
+ }
804
+ // The key's block runs until the next top-level line (or EOF); indented
805
+ // entries, indented comments, and blank lines in between all belong to it.
806
+ let end = start + 1;
807
+ while (end < lines.length && !/^\S/.test(lines[end])) end += 1;
808
+ const blockLines = block.split("\n");
809
+ // Keep the user's own spelling of the key line (quoted or bare) when it
810
+ // carries no inline value; only the entries below it are re-serialized.
811
+ if (lines[start].replace(keyLine, "").trim() === "") blockLines[0] = lines[start].replace(/\s+$/, "");
812
+ lines.splice(start, end - start, ...blockLines);
813
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
814
+ }
815
+
816
+ /**
817
+ * Extract the persistent workspace keys from a workspace yaml as a plain
818
+ * `{key: value}` object. Used to snapshot pnpm's own writes at a trusted
819
+ * moment — see the approval branch in runInstallInner for why the on-disk
820
+ * state after approved scripts ran is NOT trusted.
821
+ */
822
+ function extractPersistentWrites(content) {
823
+ const text = Buffer.isBuffer(content) ? content.toString("utf8") : content;
824
+ if (typeof text !== "string" || text.length === 0) return {};
825
+ let doc;
826
+ try {
827
+ doc = load(text) ?? {};
828
+ } catch {
829
+ return {};
830
+ }
831
+ const writes = {};
832
+ for (const key of PERSISTENT_WORKSPACE_KEYS) {
833
+ if (doc[key] !== undefined) writes[key] = doc[key];
834
+ }
835
+ return writes;
836
+ }
837
+
838
+ /**
839
+ * Merge captured persistent workspace writes into the user's pre-install
840
+ * bytes. {@link mergePersistentWorkspaceWrites} for the full story; this is
841
+ * the core that takes the writes as an object instead of a disk snapshot.
842
+ *
843
+ * Failing loudly by contract: when pnpm DID record something to merge but the
844
+ * merged text would not parse, or the spliced key does not come out exactly
845
+ * once with exactly the values requested, this THROWS. The caller
846
+ * (restoreOriginalWorkspace) turns that into a failed install with rollback.
847
+ * Silently returning the original bytes instead would report success while
848
+ * dropping the exemption — re-arming the lockfile policy against the very
849
+ * version this install just put on disk, which is the exact incident this
850
+ * merge exists to prevent. "Completed" must imply the exemption state is
851
+ * consistent with the lockfile.
852
+ */
853
+ function applyPersistentWrites(writes, originalContent) {
854
+ const original = Buffer.isBuffer(originalContent) ? originalContent.toString("utf8") : originalContent;
855
+ if (writes === null || typeof writes !== "object") {
856
+ throw new Error("persistent workspace writes must be an object");
857
+ }
858
+ const base = typeof original === "string" ? original : DEFAULT_WORKSPACE_YAML;
859
+ let baseDoc;
860
+ try {
861
+ baseDoc = load(base) ?? {};
862
+ } catch (error) {
863
+ throw new Error(`cannot merge the release-age exemption pnpm recorded: pnpm-workspace.yaml does not parse (${error.message})`);
864
+ }
865
+ let merged = base;
866
+ let changed = false;
867
+ for (const key of PERSISTENT_WORKSPACE_KEYS) {
868
+ const value = writes[key];
869
+ if (value === undefined) continue;
870
+ if (JSON.stringify(baseDoc[key]) === JSON.stringify(value)) continue;
871
+ merged = spliceWorkspaceKey(merged, key, value);
872
+ changed = true;
873
+ }
874
+ if (!changed) return original;
875
+ try {
876
+ const reparsed = load(merged) ?? {};
877
+ for (const key of PERSISTENT_WORKSPACE_KEYS) {
878
+ if (writes[key] === undefined) continue;
879
+ // The key must appear exactly once at top level (an unrecognized
880
+ // spelling in the original would otherwise leave a duplicate behind)
881
+ // and must parse back to the exact values being merged.
882
+ const occurrences = merged.split("\n").filter((line) => topLevelKeyRegex(key).test(line)).length;
883
+ if (occurrences !== 1) throw new Error("the key does not appear exactly once after the merge");
884
+ if (JSON.stringify(reparsed[key]) !== JSON.stringify(writes[key])) throw new Error("the merged value does not round-trip");
885
+ }
886
+ } catch (error) {
887
+ throw new Error(`cannot merge the release-age exemption pnpm recorded into pnpm-workspace.yaml (${error.message}) — the file uses a YAML spelling this merge does not recognize; simplify the minimumReleaseAgeExclude entry and retry`);
888
+ }
889
+ return merged;
890
+ }
891
+
892
+ /**
893
+ * Merge pnpm's own persisted workspace writes back into the user's pre-install
894
+ * bytes on the success path of an install.
895
+ *
896
+ * `pnpm add pkg@version` with the version inside the minimumReleaseAge window
897
+ * goes through and pnpm records an auto-exemption in
898
+ * `minimumReleaseAgeExclude` — a durable write to pnpm-workspace.yaml, not to
899
+ * the lockfile. The byte-restore that undoes our temporary `allowBuilds`
900
+ * neutralization wiped it along with everything else, so every marketplace
901
+ * update of a fresh release silently lost its exemption; the next unrelated
902
+ * install then failed lockfile policy verification on the very version that
903
+ * update had installed (real case: 0.4.14, updated to from the marketplace
904
+ * page, kept the profile un-installable for 24h).
905
+ *
906
+ * So restoring on success is a merge, not a byte rollback: start from the
907
+ * user's original bytes and splice in exactly the persistent keys pnpm wrote.
908
+ * When pnpm wrote nothing persistent the original bytes come back unchanged;
909
+ * when pnpm wrote something that cannot be merged safely this throws (see
910
+ * applyPersistentWrites) so the install fails and rolls back instead of
911
+ * reporting success with a dropped exemption.
912
+ *
913
+ * Only call this with a disk state no install script has had a chance to
914
+ * touch (or with a writes snapshot captured before they ran) — a script that
915
+ * got approved to execute could otherwise launder arbitrary release-age
916
+ * exemptions into the user's config as "pnpm writes".
917
+ *
918
+ * @param afterContent - pnpm-workspace.yaml as it sat on disk when pnpm
919
+ * finished (scripts still blocked, or a snapshot taken before they ran).
920
+ * @param originalContent - the user's pre-install bytes, or undefined when the
921
+ * file did not exist before (the install wrote the default template).
922
+ * @returns the bytes to leave on disk — `originalContent` as-is when pnpm
923
+ * wrote nothing persistent.
924
+ */
925
+ export function mergePersistentWorkspaceWrites(afterContent, originalContent) {
926
+ // Callers hand in the pre-save Buffer from readFileSync; normalize so the
927
+ // "no original file" case (undefined) is not confused with a Buffer.
928
+ const original = Buffer.isBuffer(originalContent) ? originalContent.toString("utf8") : originalContent;
929
+ return applyPersistentWrites(extractPersistentWrites(afterContent), original);
930
+ }
931
+
769
932
  /**
770
933
  * Neutralize allowBuilds in pnpm-workspace.yaml so that pnpm strictly blocks
771
934
  * the lifecycle scripts of everything this transaction introduces.
@@ -1680,15 +1843,29 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1680
1843
  const originalWorkspaceBytes = existsSync(workspacePath) ? readFileSync(workspacePath) : undefined;
1681
1844
  let workspaceRestored = false;
1682
1845
  let workspaceRestoreError;
1683
- const restoreOriginalWorkspace = () => {
1846
+ const restoreOriginalWorkspace = ({ preservePnpmWrites = false, persistentWrites } = {}) => {
1684
1847
  if (workspaceRestored) return true;
1685
1848
  try {
1849
+ // On success the restore is a merge: pnpm's own durable writes to the
1850
+ // workspace (the minimumReleaseAgeExclude auto-exemption for an
1851
+ // explicitly pinned fresh release) survive, everything this transaction
1852
+ // wrote on top of the user's bytes does not. See
1853
+ // mergePersistentWorkspaceWrites for why a plain byte rollback here is
1854
+ // a bug, not hygiene. `persistentWrites` overrides the disk read with a
1855
+ // snapshot captured before approved scripts ran.
1856
+ let nextBytes = originalWorkspaceBytes;
1857
+ if (preservePnpmWrites) {
1858
+ const writes = persistentWrites !== undefined
1859
+ ? persistentWrites
1860
+ : extractPersistentWrites(existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : undefined);
1861
+ nextBytes = applyPersistentWrites(writes, originalWorkspaceBytes);
1862
+ }
1686
1863
  if (_restoreWorkspace !== undefined) {
1687
- _restoreWorkspace(workspacePath, originalWorkspaceBytes);
1688
- } else if (originalWorkspaceBytes === undefined) {
1864
+ _restoreWorkspace(workspacePath, nextBytes);
1865
+ } else if (nextBytes === undefined) {
1689
1866
  rmSync(workspacePath, { force: true });
1690
1867
  } else {
1691
- writeFileSync(workspacePath, originalWorkspaceBytes);
1868
+ writeFileSync(workspacePath, nextBytes);
1692
1869
  }
1693
1870
  workspaceRestored = true;
1694
1871
  workspaceRestoreError = undefined;
@@ -1898,12 +2075,15 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1898
2075
  const ignored = parseIgnoredBuilds(log);
1899
2076
 
1900
2077
  if (ignored.length === 0) {
1901
- if (!restoreOriginalWorkspace()) {
1902
- return { status: "failed", detail: `could not restore pnpm-workspace.yaml after the script-blocking probe: ${workspaceRestoreError?.message ?? "unknown error"}` };
1903
- }
1904
2078
  if (outcome.exitCode !== 0) {
2079
+ restoreOriginalWorkspace();
1905
2080
  return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
1906
2081
  }
2082
+ // pnpm succeeded: keep its durable workspace writes (the release-age
2083
+ // exemption it may have recorded for this very spec), wipe the rest.
2084
+ if (!restoreOriginalWorkspace({ preservePnpmWrites: true })) {
2085
+ return { status: "failed", detail: `could not restore pnpm-workspace.yaml after the script-blocking probe: ${workspaceRestoreError?.message ?? "unknown error"}` };
2086
+ }
1907
2087
  return tryFinalize();
1908
2088
  }
1909
2089
 
@@ -1958,6 +2138,15 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1958
2138
  push("[dsh-plugin-mall] temporarily allowing them in the profile's pnpm-workspace.yaml and retrying once.\n");
1959
2139
 
1960
2140
  workspaceRestored = false; // allow writing temporary approved builds
2141
+ // Snapshot pnpm's persistent writes BEFORE the rebuild runs. The add that
2142
+ // produced them executed with every script blocked, so this delta is
2143
+ // provably pnpm's own. Once the approved postinstall scripts execute they
2144
+ // can write anything into this file — a broad release-age exemption would
2145
+ // otherwise be laundered into the user's config as a "pnpm write" by the
2146
+ // success-restore merge. Only this snapshot is merged afterwards.
2147
+ const preRebuildPersistentWrites = extractPersistentWrites(
2148
+ existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : undefined,
2149
+ );
1961
2150
  try {
1962
2151
  const currentWs = existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : DEFAULT_WORKSPACE_YAML;
1963
2152
  const nextWs = enableApprovedBuildSelectors(currentWs, ignored.map((entry) => entry.selector));
@@ -1971,8 +2160,16 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1971
2160
  current = retry;
1972
2161
  const retryOutcome = await retry.done;
1973
2162
 
1974
- // Restore workspace bytes on EVERY branch (including success) before finalize
1975
- if (!restoreOriginalWorkspace()) {
2163
+ // Restore workspace on EVERY branch (including success) before finalize.
2164
+ // Success restores as a merge of the PRE-REBUILD snapshot only: the
2165
+ // exemption pnpm recorded for the approved install's spec survives, the
2166
+ // temporary approved-build bytes do not, and whatever the approved
2167
+ // scripts wrote to the file during the rebuild does not either. Failure
2168
+ // paths roll the bytes back wholesale.
2169
+ const retrySucceeded = retryOutcome.spawnError === undefined
2170
+ && !endedByCancel(retryOutcome, cancelRequested)
2171
+ && retryOutcome.exitCode === 0;
2172
+ if (!restoreOriginalWorkspace(retrySucceeded ? { preservePnpmWrites: true, persistentWrites: preRebuildPersistentWrites } : {})) {
1976
2173
  return { status: "failed", detail: `approved scripts finished but pnpm-workspace.yaml could not be restored: ${workspaceRestoreError?.message ?? "unknown error"}` };
1977
2174
  }
1978
2175
 
@@ -2422,6 +2619,7 @@ function runAllowBuildsFixtures() {
2422
2619
  if (!ok && error !== undefined) console.log(` 抛错: ${error.message}`);
2423
2620
  }
2424
2621
  failed += runNeutralizeFixtures();
2622
+ failed += runMergeWritesFixtures();
2425
2623
  return failed;
2426
2624
  }
2427
2625
 
@@ -2520,6 +2718,130 @@ function runNeutralizeFixtures() {
2520
2718
  return failed;
2521
2719
  }
2522
2720
 
2721
+ // ── mergePersistentWorkspaceWrites:成功还原时留住 pnpm 的持久写入 ─────────
2722
+ //
2723
+ // 真实事故:0.4.14 从市场页更新,pnpm 为显式指定的冷却期版本记了
2724
+ // minimumReleaseAgeExclude 豁免,字节还原把它一起抹掉;此后 24 小时内任何
2725
+ // 安装都在 lockfile 供应链策略验证那步被这个「自己刚装的版本」拦下。
2726
+ // 这组钉的是合并的边界:豁免要活下来,用户的注释/键序/其余键一字不动,
2727
+ // 我们的临时中和(allowBuilds)照旧不留。
2728
+ const MERGE_WRITES_FIXTURES = [
2729
+ {
2730
+ label: "pnpm 往已有豁免条目追加版本 → 豁免更新,注释与键序不动",
2731
+ original: "packages:\n - .\n# 用户注释不许丢\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - '@scope/pkg@0.3.4'\n",
2732
+ after: "packages:\n - .\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - '@scope/pkg@0.3.4 || 0.4.14'\nallowBuilds: {}\n",
2733
+ check: (d, text) => d.minimumReleaseAgeExclude[0] === "@scope/pkg@0.3.4 || 0.4.14"
2734
+ && text.includes("# 用户注释不许丢")
2735
+ && text.indexOf("nodeLinker: hoisted") < text.indexOf("minimumReleaseAgeExclude:")
2736
+ && !text.includes("allowBuilds"),
2737
+ },
2738
+ {
2739
+ label: "原文件没有豁免键、pnpm 新增整块 → 追加到文件尾",
2740
+ original: "packages:\n - .\nnodeLinker: hoisted\n",
2741
+ after: "nodeLinker: hoisted\npackages:\n - .\nminimumReleaseAgeExclude:\n - 'fresh-pkg@1.0.0'\n",
2742
+ check: (d, text) => d.minimumReleaseAgeExclude[0] === "fresh-pkg@1.0.0"
2743
+ && text.startsWith("packages:\n - .\n")
2744
+ && text.includes("nodeLinker: hoisted"),
2745
+ },
2746
+ {
2747
+ label: "pnpm 没写任何持久键 → 字节原样返回(不是重排后的等价文本)",
2748
+ original: "packages:\n - .\nnodeLinker: hoisted\nallowBuilds:\n node-pty: true\n",
2749
+ after: "nodeLinker: hoisted\npackages:\n - .\nallowBuilds: {}\n",
2750
+ check: (d, text) => text === "packages:\n - .\nnodeLinker: hoisted\nallowBuilds:\n node-pty: true\n",
2751
+ },
2752
+ {
2753
+ label: "豁免值没变(只是文本被重排)→ 字节原样返回",
2754
+ original: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\n",
2755
+ after: "minimumReleaseAgeExclude:\n - 'a@1.0.0'\npackages:\n - .\n",
2756
+ check: (d, text) => text === "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\n",
2757
+ },
2758
+ {
2759
+ label: "原文件不存在 + pnpm 写了豁免 → 默认模板带上豁免保留下来",
2760
+ original: undefined,
2761
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'p@1.0.0'\n",
2762
+ check: (d) => d.minimumReleaseAgeExclude[0] === "p@1.0.0" && Array.isArray(d.packages) && d.packages[0] === ".",
2763
+ },
2764
+ {
2765
+ label: "原文件不存在 + pnpm 没写 → 返回 undefined(维持删除语义)",
2766
+ original: undefined,
2767
+ after: "packages:\n - .\nnodeLinker: hoisted\n",
2768
+ check: (d, text) => text === undefined,
2769
+ },
2770
+ {
2771
+ label: "磁盘内容解析不了 → 退回纯字节还原",
2772
+ original: "packages:\n - .\n",
2773
+ after: "not: [valid: yaml\n - broken",
2774
+ check: (d, text) => text === "packages:\n - .\n",
2775
+ },
2776
+ {
2777
+ label: "磁盘上没有 workspace 文件 → 退回纯字节还原",
2778
+ original: "packages:\n - .\n",
2779
+ after: undefined,
2780
+ check: (d, text) => text === "packages:\n - .\n",
2781
+ },
2782
+ {
2783
+ label: "原键是单引号写法 → 识别并替换,不追加重复键",
2784
+ original: "packages:\n - .\n'minimumReleaseAgeExclude':\n - 'a@1.0.0'\n",
2785
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0 || 9.9.9'\n",
2786
+ check: (d, text) => d.minimumReleaseAgeExclude[0] === "a@1.0.0 || 9.9.9"
2787
+ && text.split("\n").filter((l) => /['\"]?minimumReleaseAgeExclude['\"]?\s*:/.test(l)).length === 1
2788
+ && text.includes("'minimumReleaseAgeExclude':"),
2789
+ },
2790
+ {
2791
+ label: "原键是双引号写法 → 识别并替换,不追加重复键",
2792
+ original: "packages:\n - .\n\"minimumReleaseAgeExclude\":\n - 'a@1.0.0'\n",
2793
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
2794
+ check: (d, text) => d.minimumReleaseAgeExclude[0] === "a@2.0.0"
2795
+ && text.split("\n").filter((l) => /['\"]?minimumReleaseAgeExclude['\"]?\s*:/.test(l)).length === 1,
2796
+ },
2797
+ {
2798
+ label: "原文件本就有重复键(已损坏)→ 拒绝合并并抛错,绝不静默丢豁免",
2799
+ original: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\nminimumReleaseAgeExclude:\n - 'b@1.0.0'\n",
2800
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
2801
+ expectThrow: /cannot merge the release-age exemption/,
2802
+ check: () => false,
2803
+ },
2804
+ {
2805
+ label: "豁免键用 alias 引用别处(复杂写法)→ 展开替换,语义等价、锚点不动",
2806
+ original: "exemptions: &ex\n - 'a@1.0.0'\npackages:\n - .\nminimumReleaseAgeExclude: *ex\n",
2807
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
2808
+ check: (d, text) => JSON.stringify(d.minimumReleaseAgeExclude) === JSON.stringify(["a@2.0.0"])
2809
+ && text.includes("exemptions: &ex"),
2810
+ },
2811
+ {
2812
+ label: "原文件 alias 指向不存在的锚点(解析失败)→ 抛错而非静默丢豁免",
2813
+ original: "packages:\n - .\nminimumReleaseAgeExclude: *missing\n",
2814
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
2815
+ expectThrow: /does not parse/,
2816
+ check: () => false,
2817
+ },
2818
+ ];
2819
+
2820
+ function runMergeWritesFixtures() {
2821
+ let failed = 0;
2822
+ for (const fx of MERGE_WRITES_FIXTURES) {
2823
+ let ok = false;
2824
+ let out;
2825
+ try {
2826
+ out = mergePersistentWorkspaceWrites(fx.after, fx.original);
2827
+ if (fx.expectThrow !== undefined) {
2828
+ ok = false; // 该抛的没抛
2829
+ } else if (out === undefined || typeof out === "string") {
2830
+ // 产出必须可解析(undefined 除外,那是删除语义)。
2831
+ if (typeof out === "string") load(out);
2832
+ ok = fx.check(out === undefined ? undefined : load(out), out) === true;
2833
+ }
2834
+ } catch (error) {
2835
+ ok = fx.expectThrow !== undefined && fx.expectThrow.test(error?.message ?? "");
2836
+ if (!ok) console.log(` 抛错: ${error?.message}`);
2837
+ }
2838
+ if (!ok) failed++;
2839
+ console.log(` ${ok ? "PASS" : "FAIL"} merge-writes: ${fx.label}`);
2840
+ if (!ok && out !== undefined) console.log(` 产出:\n${String(out).split("\n").map((l) => ` | ${l}`).join("\n")}`);
2841
+ }
2842
+ return failed;
2843
+ }
2844
+
2523
2845
  // ── transaction fixtures (deterministic, offline) ───────────────────────────
2524
2846
  //
2525
2847
  // The findings these pin:
@@ -2924,6 +3246,203 @@ async function runTransactionFixtures() {
2924
3246
  }
2925
3247
  }
2926
3248
 
3249
+ // 1b2. 成功安装 + pnpm 为显式指定的冷却期版本记了豁免 → 豁免必须活过
3250
+ // 成功路径的 workspace 还原。0.4.14 事故的钉子:字节还原把 pnpm 的这条
3251
+ // 持久写入一起抹掉,此后 24 小时内任何安装都在 lockfile 供应链策略验证
3252
+ // 那步被「自己刚装的版本」拦死。
3253
+ {
3254
+ const { profileDir, cleanup } = makeTempProfile("release-age-exemption");
3255
+ try {
3256
+ const initialWs = "packages:\n - .\n# 用户注释不许丢\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - '@1e0zj/pkg@0.3.4'\n";
3257
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
3258
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
3259
+ const { spawnFn } = scriptedSpawn([
3260
+ {
3261
+ code: 0,
3262
+ out: "Done\n",
3263
+ beforeExit: () => {
3264
+ // 模拟 pnpm 11 的行为:显式装冷却期内的版本 → 放行并往 workspace
3265
+ // 的 minimumReleaseAgeExclude 追加记录(pnpm 会并入已有条目)。
3266
+ const wsPath = join(profileDir, "pnpm-workspace.yaml");
3267
+ const parsed = load(readFileSync(wsPath, "utf8"));
3268
+ parsed.minimumReleaseAgeExclude = ["@1e0zj/pkg@0.3.4 || 0.4.14"];
3269
+ writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
3270
+ },
3271
+ },
3272
+ ]);
3273
+ const producer = runInstall({
3274
+ profile: "p",
3275
+ spec: "some-plugin",
3276
+ preflight: preflightStub("some-plugin"),
3277
+ _profileDir: profileDir,
3278
+ _spawn: spawnFn,
3279
+ _describe: describeStub,
3280
+ });
3281
+ const outcome = await producer.done;
3282
+ const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
3283
+ const finalDoc = load(finalWs);
3284
+ check(
3285
+ "退出码 0 + pnpm 记了冷却豁免 → 豁免活过还原,注释与用户键保留、中和态不残留",
3286
+ outcome.status === "completed"
3287
+ && finalDoc.minimumReleaseAgeExclude[0] === "@1e0zj/pkg@0.3.4 || 0.4.14"
3288
+ && finalWs.includes("# 用户注释不许丢")
3289
+ && finalWs.includes("nodeLinker: hoisted")
3290
+ && finalDoc.allowBuilds === undefined,
3291
+ `status=${outcome.status} finalWs=${JSON.stringify(finalWs)}`,
3292
+ );
3293
+ } finally {
3294
+ cleanup();
3295
+ }
3296
+ }
3297
+
3298
+ // 1b3. 反向边界:pnpm 失败(哪怕豁免已落盘)→ 字节还原,豁免不保留——
3299
+ // 没装成就不欠供应链策略的债。
3300
+ {
3301
+ const { profileDir, cleanup } = makeTempProfile("release-age-exemption-failed");
3302
+ try {
3303
+ const initialWs = "packages:\n - .\nnodeLinker: hoisted\n";
3304
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
3305
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
3306
+ const { spawnFn } = scriptedSpawn([
3307
+ {
3308
+ code: 1,
3309
+ out: "ERR_PNPM_NO_MATCHING_VERSION\n",
3310
+ beforeExit: () => {
3311
+ const wsPath = join(profileDir, "pnpm-workspace.yaml");
3312
+ const parsed = load(readFileSync(wsPath, "utf8"));
3313
+ parsed.minimumReleaseAgeExclude = ["some-plugin@9.9.9"];
3314
+ writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
3315
+ },
3316
+ },
3317
+ ]);
3318
+ const producer = runInstall({
3319
+ profile: "p",
3320
+ spec: "some-plugin",
3321
+ preflight: preflightStub("some-plugin"),
3322
+ _profileDir: profileDir,
3323
+ _spawn: spawnFn,
3324
+ _describe: describeStub,
3325
+ });
3326
+ const outcome = await producer.done;
3327
+ const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
3328
+ check(
3329
+ "退出码非 0(豁免已落盘)→ 字节还原,豁免不保留",
3330
+ outcome.status === "failed" && finalWs === initialWs,
3331
+ `status=${outcome.status} finalWs=${JSON.stringify(finalWs)}`,
3332
+ );
3333
+ } finally {
3334
+ cleanup();
3335
+ }
3336
+ }
3337
+
3338
+ // 1b4. 批准执行的构建脚本篡改豁免 → 只有 rebuild 前捕获的 pnpm 豁免被
3339
+ // 合并。已批准的 postinstall 在 rebuild 里跑过之后,磁盘上的 workspace
3340
+ // 谁都能写:宽泛豁免若被当「pnpm 持久写入」合并,等于借审批之手把
3341
+ // 供应链冷却期防线拆了。
3342
+ {
3343
+ const { profileDir, cleanup } = makeTempProfile("exemption-laundering");
3344
+ try {
3345
+ const initialWs = "packages:\n - .\nnodeLinker: hoisted\n";
3346
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
3347
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
3348
+ materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
3349
+ const approvedProof = computeMaterializedProof(profileDir, "some-plugin", [{ name: "node-pty", version: "1.0.0", selector: "node-pty@1.0.0" }]);
3350
+ const wsPath = join(profileDir, "pnpm-workspace.yaml");
3351
+ const writeWs = (exemptions) => {
3352
+ const parsed = load(readFileSync(wsPath, "utf8"));
3353
+ parsed.minimumReleaseAgeExclude = exemptions;
3354
+ writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
3355
+ };
3356
+ const { spawnFn } = scriptedSpawn([
3357
+ {
3358
+ // 第一次 add:脚本全禁,pnpm 为显式指定的冷却期版本记豁免。
3359
+ code: 0,
3360
+ out: "Ignored build scripts: node-pty@1.0.0\n",
3361
+ beforeExit: () => writeWs(["some-plugin@1.0.0"]),
3362
+ },
3363
+ {
3364
+ // rebuild:node-pty 的 postinstall(已获批准)顺手篡改豁免。
3365
+ code: 0,
3366
+ out: "Done\n",
3367
+ beforeExit: () => writeWs(["some-plugin@1.0.0", "evil-pkg@*"]),
3368
+ },
3369
+ ]);
3370
+ const producer = runInstall({
3371
+ profile: "p",
3372
+ spec: "some-plugin",
3373
+ allowBuildScripts: ["node-pty"],
3374
+ approvedProof,
3375
+ preflight: preflightStub("some-plugin"),
3376
+ _profileDir: profileDir,
3377
+ _spawn: spawnFn,
3378
+ _describe: describeStub,
3379
+ });
3380
+ const outcome = await producer.done;
3381
+ const finalWs = readFileSync(wsPath, "utf8");
3382
+ const finalDoc = load(finalWs);
3383
+ check(
3384
+ "批准脚本在 rebuild 里篡改豁免 → 只有 rebuild 前的 pnpm 豁免被合并,篡改不进用户配置",
3385
+ outcome.status === "completed"
3386
+ && JSON.stringify(finalDoc.minimumReleaseAgeExclude) === JSON.stringify(["some-plugin@1.0.0"])
3387
+ && finalWs.includes("nodeLinker: hoisted")
3388
+ && finalDoc.allowBuilds === undefined,
3389
+ `status=${outcome.status} exemptions=${JSON.stringify(finalDoc.minimumReleaseAgeExclude)}`,
3390
+ );
3391
+ } finally {
3392
+ cleanup();
3393
+ }
3394
+ }
3395
+
3396
+ // 1b5. 端到端:pnpm 成功记了豁免,但用户的 workspace 用了合并器不认识的
3397
+ // 写法(flow mapping——load 认得、顶格键定位不认得)→ 合并失败必须让
3398
+ // 安装 failed 并回滚 profile,而不是报成功、静默丢豁免把雷留给下一次安装。
3399
+ {
3400
+ const { profileDir, cleanup } = makeTempProfile("exemption-merge-unmergeable");
3401
+ try {
3402
+ const initialWs = "{packages: ['.'], nodeLinker: hoisted, minimumReleaseAgeExclude: [a@1.0.0]}\n";
3403
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
3404
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
3405
+ const manifestBefore = readFileSync(join(profileDir, "package.json"), "utf8");
3406
+ const wsPath = join(profileDir, "pnpm-workspace.yaml");
3407
+ const { spawnFn } = scriptedSpawn([
3408
+ {
3409
+ code: 0,
3410
+ out: "Done\n",
3411
+ beforeExit: () => {
3412
+ // 模拟 pnpm:显式冷却期版本放行 + 记豁免 + 把依赖写进 manifest。
3413
+ // pnpm 读写的是被 neutralize 重排过的 block 风格文件。
3414
+ const parsed = load(readFileSync(wsPath, "utf8"));
3415
+ parsed.minimumReleaseAgeExclude = ["a@2.0.0"];
3416
+ writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
3417
+ const pkg = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
3418
+ pkg.dependencies = { ...(pkg.dependencies ?? {}), "some-plugin": "1.0.0" };
3419
+ writeFileSync(join(profileDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
3420
+ },
3421
+ },
3422
+ ]);
3423
+ const outcome = await runInstall({
3424
+ profile: "p",
3425
+ spec: "some-plugin",
3426
+ preflight: preflightStub("some-plugin"),
3427
+ _profileDir: profileDir,
3428
+ _spawn: spawnFn,
3429
+ _describe: describeStub,
3430
+ }).done;
3431
+ const finalWs = readFileSync(wsPath, "utf8");
3432
+ check(
3433
+ "pnpm 记豁免但 workspace 写法无法安全合并 → failed + 明说原因 + profile 原样 + marker 清除",
3434
+ outcome.status === "failed"
3435
+ && /cannot merge the release-age exemption/.test(outcome.detail ?? "")
3436
+ && finalWs === initialWs
3437
+ && readFileSync(join(profileDir, "package.json"), "utf8") === manifestBefore
3438
+ && !existsSync(pendingMarkerPath(profileDir)),
3439
+ `status=${outcome.status} detail=${JSON.stringify(outcome.detail)} finalWs=${JSON.stringify(finalWs)}`,
3440
+ );
3441
+ } finally {
3442
+ cleanup();
3443
+ }
3444
+ }
3445
+
2927
3446
  // 1c. 攻击防御:批准后修改 postinstall / 文件内容 → retry 时 proof 校验失败,绝不重试并回滚
2928
3447
  {
2929
3448
  const { profileDir, cleanup } = makeTempProfile("attack-tampered-proof");
@@ -3889,7 +4408,9 @@ async function runTransactionFixtures() {
3889
4408
  */
3890
4409
  function runToggleFixtures() {
3891
4410
  let failed = 0;
4411
+ let total = 0;
3892
4412
  const check = (label, ok, extra = "") => {
4413
+ total += 1;
3893
4414
  if (!ok) failed++;
3894
4415
  console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` ${extra}`}`);
3895
4416
  };
@@ -4028,16 +4549,20 @@ function runToggleFixtures() {
4028
4549
  }
4029
4550
  })());
4030
4551
 
4031
- return failed;
4552
+ return { failed, total };
4032
4553
  }
4033
4554
 
4034
4555
  if (process.argv[1]?.endsWith("installer.js") && process.argv.includes("--self-test")) {
4035
4556
  console.log("启用/停用 patch 层 fixtures:");
4036
- const toggleFailed = runToggleFixtures();
4557
+ const toggle = runToggleFixtures();
4037
4558
  console.log();
4038
4559
  console.log("allowBuilds 合并 fixtures:");
4039
- const failed = runAllowBuildsFixtures() + toggleFailed;
4040
- console.log(`${ALLOW_BUILDS_FIXTURES.length - failed}/${ALLOW_BUILDS_FIXTURES.length} passed`);
4560
+ const failed = runAllowBuildsFixtures() + toggle.failed;
4561
+ // 分母要数全跑过的用例(toggle/neutralize/merge-writes 也在这批里),
4562
+ // 只数 ALLOW_BUILDS_FIXTURES 会把后来加的组漏在「10/10」的假象外。
4563
+ const fixtureTotal = ALLOW_BUILDS_FIXTURES.length + NEUTRALIZE_FIXTURES.length
4564
+ + MERGE_WRITES_FIXTURES.length + toggle.total;
4565
+ console.log(`${fixtureTotal - failed}/${fixtureTotal} passed`);
4041
4566
  // 实装 pnpm add 的参数/环境(纯函数):peer 自动安装必须关闭,否则
4042
4567
  // marketplace 安装会把 @deepseek-ai 宿主依赖栈拉进 profile;构建脚本必须
4043
4568
  // 严格,否则 pnpm 退出码 0 却跳过构建脚本,批准闸形同虚设。