@1e0zj/dsh-plugin-mall 0.4.14 → 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.14",
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/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 由调用方直接续装;有风险由调用方出内联卡片。
@@ -789,7 +819,7 @@ window.__ModuleLoader__.load({
789
819
  // Opaque one-shot approval tokens issued from needsApproval outcomes (spec -> token).
790
820
  // Cleared on success, unrelated failure, cancel, modal close, or spec change.
791
821
  // No boolean warning-consent map is kept.
792
- var sessionNonce = useRef(generateSessionNonce()).current;
822
+ var sessionNonce = useRef(readOrCreateBrowserSession()).current;
793
823
  var approvalTokensRef = useRef({});
794
824
  var restartControlRef = useRef({ mounted: true, ping: null });
795
825
  var clearRestartPing = useCallback(function () {
@@ -1045,9 +1075,7 @@ window.__ModuleLoader__.load({
1045
1075
  // 响应语义是「已调度交接」而不是「重启成功」:新宿主何时接管
1046
1076
  // 由下面的轮询判断。这里只把输出会出现在哪里告诉用户。
1047
1077
  scheduledLogPath = value && value.logPath ? String(value.logPath) : null;
1048
- setRestartNote(value && value.mode === "visible"
1049
- ? "重启已调度——dsh 正在新的控制台窗口中启动,窗口里 Ctrl+C 可停止;输出同时写入 " + scheduledLogPath
1050
- : "重启已调度,输出与日志:" + (scheduledLogPath || "<home>/guard/ 下"));
1078
+ setRestartNote("重启中,输出与日志:" + (scheduledLogPath || "<home>/guard/ 下"));
1051
1079
  var tries = 0;
1052
1080
  var ping = setInterval(function () {
1053
1081
  if (restartControlRef.current.mounted !== true) {
package/src/index.js CHANGED
@@ -3217,6 +3217,98 @@ 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
+
3220
3312
  let cancelRefused = false;
3221
3313
  try {
3222
3314
  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 却跳过构建脚本,批准闸形同虚设。