@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 +72 -0
- package/package.json +1 -1
- package/src/client.js +32 -4
- package/src/index.js +92 -0
- package/src/installer.js +538 -13
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
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(
|
|
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(
|
|
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,
|
|
1688
|
-
} else if (
|
|
1864
|
+
_restoreWorkspace(workspacePath, nextBytes);
|
|
1865
|
+
} else if (nextBytes === undefined) {
|
|
1689
1866
|
rmSync(workspacePath, { force: true });
|
|
1690
1867
|
} else {
|
|
1691
|
-
writeFileSync(workspacePath,
|
|
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
|
|
1975
|
-
|
|
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
|
|
4557
|
+
const toggle = runToggleFixtures();
|
|
4037
4558
|
console.log();
|
|
4038
4559
|
console.log("allowBuilds 合并 fixtures:");
|
|
4039
|
-
const failed = runAllowBuildsFixtures() +
|
|
4040
|
-
|
|
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 却跳过构建脚本,批准闸形同虚设。
|