@1e0zj/dsh-plugin-mall 0.1.18 → 0.2.0

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/src/guard.js ADDED
@@ -0,0 +1,2413 @@
1
+ // dsh plugin conflict guard.
2
+ //
3
+ // The marketplace itself runs inside dsh, but the checks in this module are
4
+ // deliberately host-independent: a candidate is installed with scripts
5
+ // disabled into a disposable directory, inspected, and compared with the
6
+ // profile before the live profile is touched. The same functions are used by
7
+ // the browser marketplace, agent tool, and the external ds/dsh wrapper.
8
+
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ import {
11
+ chmodSync,
12
+ copyFileSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ readdirSync,
18
+ rmSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { homedir, tmpdir } from "node:os";
22
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
23
+ import { createRequire } from "node:module";
24
+ import { JSON_SCHEMA, Type, load } from "js-yaml";
25
+ import { satisfies, validRange } from "semver";
26
+
27
+ // Official patches (e.g. @deepseek-ai/dsh-base, dsh-web-app) mark raw JS
28
+ // expressions with the scalar tag `!!js`. Construct it as the inert source
29
+ // text — never evaluate it — on top of a safe schema, so every other unknown
30
+ // tag is still rejected as invalid YAML.
31
+ const JS_SCALAR_TYPE = new Type("tag:yaml.org,2002:js", {
32
+ kind: "scalar",
33
+ construct: (data) => String(data),
34
+ });
35
+ const PATCH_SCHEMA = JSON_SCHEMA.extend([JS_SCALAR_TYPE]);
36
+
37
+ const PROFILE_FILES = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "cordis.patch.yml"];
38
+ const HOST_PACKAGE_RE = /^@deepseek-ai\//;
39
+ const NPM_PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
40
+ // Snapshot/pending schema version. v2 makes the original dependency list and
41
+ // the candidate identity MANDATORY: a rollback that cannot name the candidate
42
+ // or the profile's original direct dependencies must fail closed. v1 markers
43
+ // (which allowed both to be absent) are deliberately rejected by
44
+ // sanitizeSnapshot and left on disk for manual recovery.
45
+ const SNAPSHOT_VERSION = 2;
46
+ const SNAPSHOT_ID_RE = /^[0-9]+-[a-z0-9]+$/;
47
+ const PENDING_OPERATIONS = new Set(["install", "remove"]);
48
+
49
+ function readJson(filePath) {
50
+ return JSON.parse(readFileSync(filePath, "utf8"));
51
+ }
52
+
53
+ function issue(severity, code, title, detail, extra = {}) {
54
+ return { severity, code, title, detail, ...extra };
55
+ }
56
+
57
+ function normalizeStringList(value) {
58
+ if (typeof value === "string") return [value];
59
+ return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
60
+ }
61
+
62
+ /**
63
+ * Strip a version suffix from an npm-style package spec, handling scoped names
64
+ * ("@scope/name@1.2.3" → "@scope/name") as well as bare ones ("name@1.2.3" →
65
+ * "name"). A plain `split("@")[0]` breaks scoped names: "@scope/name@1.2.3"
66
+ * would yield "" and "@scope/name" would yield "@scope".
67
+ */
68
+ function npmNameOfSpec(spec) {
69
+ const value = String(spec ?? "").trim();
70
+ if (value.length === 0) return value;
71
+ if (value.startsWith("@")) {
72
+ const match = /^(@[^@/\s]+\/[^@/\s]+?)(?:@.+)?$/.exec(value);
73
+ return match === null ? value : match[1];
74
+ }
75
+ const match = /^([^@/\s]+?)(?:@.+)?$/.exec(value);
76
+ return match === null ? value : match[1];
77
+ }
78
+
79
+ function packageJsonPathOf(packageName, anchorDir) {
80
+ if (typeof packageName !== "string" || !NPM_PACKAGE_NAME_RE.test(packageName)) return undefined;
81
+ const modulesRoot = resolve(anchorDir, "node_modules");
82
+ const direct = resolve(modulesRoot, ...packageName.split("/"), "package.json");
83
+ const rel = relative(modulesRoot, direct);
84
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
85
+ if (existsSync(direct)) return direct;
86
+ return undefined;
87
+ }
88
+
89
+ /**
90
+ * Upward (Node-semantics) variant used ONLY for @deepseek-ai/* host peers:
91
+ * the host stack lives in the shared profiles/node_modules, an ancestor of
92
+ * the profile's own node_modules, so a strict profile-only lookup can never
93
+ * resolve it — which used to emit a bogus "peer-unresolved" warning for every
94
+ * well-declared plugin. Direct profile dependencies deliberately stay on the
95
+ * strict path above (ancestor-only resolution is the fingerprint of a crashed
96
+ * install, see the ancestor fixture in selfTest).
97
+ */
98
+ function hostPackageJsonPathOf(packageName, anchorDir) {
99
+ const direct = packageJsonPathOf(packageName, anchorDir);
100
+ if (direct !== undefined) return direct;
101
+ try {
102
+ return createRequire(join(anchorDir, "noop.js")).resolve(`${packageName}/package.json`);
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ function packageInfo(packageName, anchorDir) {
109
+ const manifestPath = packageJsonPathOf(packageName, anchorDir);
110
+ if (manifestPath === undefined) return undefined;
111
+ try {
112
+ const manifest = readJson(manifestPath);
113
+ if (manifest === null || typeof manifest !== "object") return undefined;
114
+ if (manifest.name !== packageName) return undefined;
115
+ return { manifestPath, dir: dirname(manifestPath), manifest };
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /** packageInfo over hostPackageJsonPathOf — only for @deepseek-ai/* peers. */
122
+ function hostPackageInfo(packageName, anchorDir) {
123
+ const manifestPath = hostPackageJsonPathOf(packageName, anchorDir);
124
+ if (manifestPath === undefined) return undefined;
125
+ try {
126
+ const manifest = readJson(manifestPath);
127
+ if (manifest === null || typeof manifest !== "object") return undefined;
128
+ if (manifest.name !== packageName) return undefined;
129
+ return { manifestPath, dir: dirname(manifestPath), manifest };
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ function parsePatchDocument(document, source, issues, owner) {
136
+ if (document === null || document === undefined) return [];
137
+ if (!Array.isArray(document)) {
138
+ issues.push(issue("block", "patch-shape", "插件补丁结构错误", `${owner} 的补丁顶层必须是数组。`, { package: owner }));
139
+ return [];
140
+ }
141
+ const rows = [];
142
+ for (const entry of document) {
143
+ if (!Array.isArray(entry?.insert)) continue;
144
+ for (const row of entry.insert) {
145
+ if (row === null || typeof row !== "object") continue;
146
+ const id = typeof row.id === "string" ? row.id.trim() : "";
147
+ const name = typeof row.name === "string" ? row.name.trim() : "";
148
+ if (id.length === 0 || name.length === 0) continue;
149
+ rows.push({ id, name, source, owner });
150
+ }
151
+ }
152
+ return rows;
153
+ }
154
+
155
+ function parsePatch(filePath, source, issues, owner) {
156
+ if (!existsSync(filePath)) {
157
+ issues.push(issue("block", "patch-missing", "插件补丁文件不存在", `${owner} 声明了 ${filePath},但文件不存在。`, { package: owner }));
158
+ return [];
159
+ }
160
+ let document;
161
+ try {
162
+ document = load(readFileSync(filePath, "utf8"), { schema: PATCH_SCHEMA });
163
+ } catch (error) {
164
+ issues.push(issue("block", "patch-invalid", "插件补丁无法解析", `${owner} 的 ${basename(filePath)} 不是有效 YAML:${error.message}`, { package: owner }));
165
+ return [];
166
+ }
167
+ return parsePatchDocument(document, source, issues, owner);
168
+ }
169
+
170
+ /**
171
+ * Parse a bundle patch fetched as text (the browsing-time remote scan never
172
+ * writes the candidate to disk). Shape/parse failures are blockers, exactly as
173
+ * for an on-disk patch.
174
+ */
175
+ function parsePatchText(text, source, issues, owner) {
176
+ let document;
177
+ try {
178
+ document = load(String(text), { schema: PATCH_SCHEMA });
179
+ } catch (error) {
180
+ issues.push(issue("block", "patch-invalid", "插件补丁无法解析", `${owner} 的补丁不是有效 YAML:${error.message}`, { package: owner }));
181
+ return [];
182
+ }
183
+ return parsePatchDocument(document, source, issues, owner);
184
+ }
185
+
186
+ /**
187
+ * Resolve a bundle's `dsh.bundle.patch` path and clamp it to the package
188
+ * directory. A patch ships inside the package it belongs to; a path that
189
+ * resolves outside that directory (`../`, an absolute path) is untrusted input
190
+ * the guard must never open, so it is reported as a blocker instead of read.
191
+ */
192
+ function bundlePatchPath(info, issues, owner) {
193
+ const patch = info?.manifest?.dsh?.bundle?.patch;
194
+ if (typeof patch !== "string") return undefined;
195
+ const root = resolve(info.dir);
196
+ const target = resolve(root, patch);
197
+ const rel = relative(root, target);
198
+ const outside = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
199
+ if (outside) {
200
+ issues.push(issue("block", "patch-outside-package", "插件补丁路径越界", `${owner} 的 dsh.bundle.patch ${JSON.stringify(patch)} 解析到包目录之外(${target}),拒绝读取。`, { package: owner }));
201
+ return undefined;
202
+ }
203
+ return target;
204
+ }
205
+
206
+ function rowsForPackage(info, issues, source = "bundle") {
207
+ const owner = info.manifest.name ?? basename(info.dir);
208
+ const patchPath = bundlePatchPath(info, issues, owner);
209
+ if (patchPath === undefined) return [];
210
+ return parsePatch(patchPath, source, issues, owner);
211
+ }
212
+
213
+ function clientRowId(packageName) {
214
+ const last = packageName.split("/").pop() ?? packageName;
215
+ const trimmed = last.replace(/^dsh-/, "").replace(/^client-ui-/, "").replace(/^client-/, "");
216
+ return trimmed.length > 0 ? trimmed : last;
217
+ }
218
+
219
+ function installedProfile(profileDir, issues) {
220
+ const manifestPath = join(profileDir, "package.json");
221
+ const manifest = readJson(manifestPath);
222
+ const dependencies = Object.keys(manifest.dependencies ?? {});
223
+ const bundles = manifest.dsh?.profile?.bundles ?? [];
224
+ const packages = new Map();
225
+ const rows = [];
226
+ for (const name of new Set([...dependencies, ...bundles])) {
227
+ const info = packageInfo(name, profileDir);
228
+ if (info === undefined) {
229
+ // A dependency the manifest declares but node_modules cannot resolve (or
230
+ // whose package.json cannot be read) is the fingerprint of a crash
231
+ // mid-install: pnpm updated package.json before materializing the package.
232
+ // Skipping it would let recoverProfile commit a profile dsh cannot load.
233
+ // Template bundles appear only in `bundles`, never in `dependencies`, so
234
+ // they stay silent here (they are in-box, not expected under node_modules).
235
+ if (dependencies.includes(name)) {
236
+ issues.push(issue("block", "package-unresolved", "依赖无法解析", `package.json 声明了依赖 ${name},但 node_modules 中无法解析或读取其 package.json(安装可能未完成)。`, { package: name }));
237
+ }
238
+ continue;
239
+ }
240
+ packages.set(name, info.manifest);
241
+ if (bundles.includes(name)) rows.push(...rowsForPackage(info, issues, "bundle"));
242
+ }
243
+ const profilePatch = join(profileDir, "cordis.patch.yml");
244
+ if (existsSync(profilePatch)) rows.push(...parsePatch(profilePatch, "profile", issues, "profile cordis.patch.yml"));
245
+ return { manifest, dependencies, bundles, packages, rows };
246
+ }
247
+
248
+ function compatibilityIssues(candidate, current, profileDir) {
249
+ const issues = [];
250
+ const candidateName = candidate.manifest.name ?? "unknown-package";
251
+ const isDeclaredOfficial = HOST_PACKAGE_RE.test(candidateName) && (
252
+ (Array.isArray(current?.dependencies) && current.dependencies.includes(candidateName)) ||
253
+ (Array.isArray(current?.bundles) && current.bundles.includes(candidateName))
254
+ );
255
+
256
+ if (HOST_PACKAGE_RE.test(candidateName) && !isDeclaredOfficial) {
257
+ issues.push(issue(
258
+ "block",
259
+ "official-package-spoof",
260
+ "禁止安装未声明的官方作用域包",
261
+ `${candidateName} 使用了 @deepseek-ai/* 官方作用域,但未在当前 profile 中声明;禁止外部安装未声明的官方作用域包。`,
262
+ { package: candidateName },
263
+ ));
264
+ }
265
+
266
+ if (!isDeclaredOfficial) {
267
+ const hostDeps = Object.keys(candidate.manifest.dependencies ?? {}).filter((name) => HOST_PACKAGE_RE.test(name));
268
+ if (hostDeps.length > 0) {
269
+ issues.push(issue(
270
+ "block",
271
+ "host-module-shadow",
272
+ "插件会复制 DSH 宿主模块",
273
+ `${candidateName} 把 ${hostDeps.join(", ")} 放在 dependencies 中,会产生双模块实例并破坏工具调度;插件作者应改用 peerDependencies。`,
274
+ { package: candidateName, conflictsWith: hostDeps },
275
+ ));
276
+ }
277
+ }
278
+
279
+ const engineRange = candidate.manifest.engines?.node;
280
+ if (typeof engineRange === "string") {
281
+ try {
282
+ if (!satisfies(process.versions.node, engineRange, { includePrerelease: true, loose: true })) {
283
+ issues.push(issue("block", "node-version", "Node.js 版本不兼容", `${candidateName} 要求 Node ${engineRange},当前是 ${process.versions.node}。`, { package: candidateName }));
284
+ }
285
+ } catch {
286
+ issues.push(issue("warn", "node-range-unknown", "无法判断 Node.js 兼容性", `${candidateName} 使用了无法识别的 engines.node 范围 ${engineRange}。`, { package: candidateName }));
287
+ }
288
+ }
289
+
290
+ const supportedOs = Array.isArray(candidate.manifest.os) ? candidate.manifest.os.map(String) : undefined;
291
+ if (supportedOs !== undefined && supportedOs.length > 0) {
292
+ const denied = supportedOs.includes(`!${process.platform}`);
293
+ const positives = supportedOs.filter((name) => !name.startsWith("!"));
294
+ if (denied || (positives.length > 0 && !positives.includes(process.platform))) {
295
+ issues.push(issue("block", "os-incompatible", "当前系统不受支持", `${candidateName} 声明支持 ${supportedOs.join(", ")},当前平台是 ${process.platform}。`, { package: candidateName }));
296
+ }
297
+ }
298
+
299
+ for (const [peerName, range] of Object.entries(candidate.manifest.peerDependencies ?? {})) {
300
+ if (!HOST_PACKAGE_RE.test(peerName) || range === "*") continue;
301
+ const host = hostPackageInfo(peerName, profileDir);
302
+ if (host === undefined) {
303
+ issues.push(issue("warn", "peer-unresolved", "无法验证宿主依赖", `${candidateName} 需要 ${peerName}@${range},但预检无法解析宿主版本。`, { package: candidateName, conflictsWith: [peerName] }));
304
+ continue;
305
+ }
306
+ try {
307
+ if (!satisfies(host.manifest.version, String(range), { includePrerelease: true, loose: true })) {
308
+ issues.push(issue("block", "peer-version", "DSH 组件版本不兼容", `${candidateName} 需要 ${peerName}@${range},当前宿主是 ${host.manifest.version}。`, { package: candidateName, conflictsWith: [peerName] }));
309
+ }
310
+ } catch {
311
+ issues.push(issue("warn", "peer-range-unknown", "无法判断 DSH 组件兼容性", `${candidateName} 对 ${peerName} 使用了无法识别的版本范围 ${range}。`, { package: candidateName, conflictsWith: [peerName] }));
312
+ }
313
+ }
314
+
315
+ const installedNames = new Set(current.dependencies);
316
+ for (const pattern of normalizeStringList(candidate.manifest.dsh?.conflicts)) {
317
+ const name = npmNameOfSpec(pattern) || pattern;
318
+ // Self-declared conflicts are meaningless: a plugin cannot be
319
+ // "incompatible with itself", and an update of an already-installed plugin
320
+ // would otherwise block on its own conflict list.
321
+ if (name === candidateName) continue;
322
+ if (installedNames.has(name)) {
323
+ issues.push(issue("block", "declared-conflict", "插件声明了不兼容项", `${candidateName} 声明与已安装的 ${pattern} 冲突。`, { package: candidateName, conflictsWith: [name] }));
324
+ }
325
+ }
326
+
327
+ const candidateGroups = new Set(normalizeStringList(candidate.manifest.dsh?.exclusiveGroups));
328
+ if (candidateGroups.size > 0) {
329
+ for (const [name, manifest] of current.packages) {
330
+ if (name === candidateName) continue; // updating itself is not a conflict
331
+ const overlap = normalizeStringList(manifest.dsh?.exclusiveGroups).filter((group) => candidateGroups.has(group));
332
+ if (overlap.length > 0) {
333
+ issues.push(issue("block", "exclusive-group", "插件占用了同一独占功能", `${candidateName} 与 ${name} 都声明了独占组 ${overlap.join(", ")}。`, { package: candidateName, conflictsWith: [name] }));
334
+ }
335
+ }
336
+ }
337
+ return issues;
338
+ }
339
+
340
+ function rowConflictIssues(candidateName, candidateRows, existingRows) {
341
+ const issues = [];
342
+ for (let index = 0; index < candidateRows.length; index++) {
343
+ const row = candidateRows[index];
344
+ for (let otherIndex = index + 1; otherIndex < candidateRows.length; otherIndex++) {
345
+ const other = candidateRows[otherIndex];
346
+ if (row.id === other.id && row.name !== other.name) {
347
+ issues.push(issue("block", "candidate-duplicate-id", "插件内部存在重复加载 ID", `${candidateName} 在同一补丁中用 id=${row.id} 加载 ${row.name} 和 ${other.name}。`, { package: candidateName }));
348
+ }
349
+ if (!HOST_PACKAGE_RE.test(candidateName) && row.name === other.name && row.id !== other.id) {
350
+ issues.push(issue("block", "candidate-double-mount", "插件内部会重复挂载模块", `${row.name} 同时使用 id=${row.id} 和 id=${other.id}。`, { package: candidateName }));
351
+ }
352
+ }
353
+ for (const existing of existingRows) {
354
+ // Updating a package naturally compares against its currently mounted
355
+ // row. The same id+name is an update, not a collision.
356
+ if (existing.owner === candidateName && existing.id === row.id && existing.name === row.name) continue;
357
+ if (existing.id === row.id && existing.name !== row.name) {
358
+ issues.push(issue("block", "loader-id-collision", "加载 ID 已被其他插件占用", `候选插件要用 id=${row.id} 加载 ${row.name},但 ${existing.owner} 已用它加载 ${existing.name}。`, { package: candidateName, conflictsWith: [existing.owner] }));
359
+ } else if (existing.name === row.name && existing.id !== row.id) {
360
+ issues.push(issue("block", "double-mount", "同一模块会被挂载两次", `${row.name} 已由 ${existing.owner} 以 id=${existing.id} 挂载,候选插件还会以 id=${row.id} 再挂一次。`, { package: candidateName, conflictsWith: [existing.owner] }));
361
+ }
362
+ }
363
+ }
364
+ return issues;
365
+ }
366
+
367
+ /** Pairwise scan of already-mounted loader rows for id/name collisions. */
368
+ function detectRowConflicts(rows) {
369
+ const issues = [];
370
+ for (let index = 0; index < rows.length; index++) {
371
+ const row = rows[index];
372
+ for (let otherIndex = index + 1; otherIndex < rows.length; otherIndex++) {
373
+ const other = rows[otherIndex];
374
+ if (row.id === other.id && row.name !== other.name) {
375
+ issues.push(issue("block", "loader-id-collision", "加载 ID 被重复占用", `${row.owner} 用 id=${row.id} 加载 ${row.name},而 ${other.owner} 也用它加载 ${other.name}。`, { conflictsWith: [row.owner, other.owner] }));
376
+ } else if (row.owner !== other.owner && row.name === other.name && row.id !== other.id) {
377
+ issues.push(issue("block", "double-mount", "同一模块被挂载两次", `${row.name} 同时被 ${row.owner}(id=${row.id})和 ${other.owner}(id=${other.id})挂载。`, { conflictsWith: [row.owner, other.owner] }));
378
+ }
379
+ }
380
+ }
381
+ return issues;
382
+ }
383
+
384
+ /**
385
+ * Validate an installed profile as it stands on disk, host-independently. The
386
+ * checks here are exactly the ones that prevent dsh from composing a loadable
387
+ * entry tree — unparseable manifests/patches, loader-id collisions, double
388
+ * mounts, and host-module shadowing — so a bad install can be caught and
389
+ * rolled back without booting dsh. Softer concerns (peer/OS/Node ranges) were
390
+ * already surfaced by the install preflight and are deliberately not repeated
391
+ * here: flagging them would roll back installs that still load fine.
392
+ * @returns {{ok: boolean, verdict: string, issues: object[], summary: string}}
393
+ */
394
+ export function validateInstalledProfile(profileDir) {
395
+ const issues = [];
396
+ let current;
397
+ try {
398
+ current = installedProfile(profileDir, issues);
399
+ } catch (error) {
400
+ return {
401
+ ok: false,
402
+ verdict: "blocked",
403
+ issues: [issue("block", "profile-broken", "profile 配置无法读取", `${profileDir} 无法解析:${error.message}`)],
404
+ summary: "profile 配置无法读取,需回滚",
405
+ };
406
+ }
407
+ for (const [name, manifest] of current.packages) {
408
+ if (HOST_PACKAGE_RE.test(name)) continue;
409
+ const hostDeps = Object.keys(manifest.dependencies ?? {}).filter((depName) => HOST_PACKAGE_RE.test(depName));
410
+ if (hostDeps.length > 0) {
411
+ issues.push(issue("block", "host-module-shadow", "插件复制了 DSH 宿主模块", `${name} 把 ${hostDeps.join(", ")} 放在 dependencies 中,会产生双模块实例并破坏工具调度。`, { package: name, conflictsWith: hostDeps }));
412
+ }
413
+ }
414
+ issues.push(...detectRowConflicts(current.rows));
415
+ const blockers = issues.filter((entry) => entry.severity === "block");
416
+ const warnings = issues.filter((entry) => entry.severity === "warn");
417
+ const verdict = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "safe";
418
+ return {
419
+ ok: blockers.length === 0,
420
+ verdict,
421
+ issues,
422
+ summary: blockers.length > 0
423
+ ? `发现 ${blockers.length} 个阻断问题、${warnings.length} 个警告`
424
+ : warnings.length > 0 ? `未发现阻断问题,但有 ${warnings.length} 个警告` : "profile 配置可正常加载",
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Remove leftover `node_modules` entries for packages that are no longer part
430
+ * of the profile's declared dependencies. pnpm hoists each direct dependency
431
+ * as a top-level `node_modules/<name>` symlink (or directory); removing that
432
+ * link is safe and idempotent — the `.pnpm` virtual store underneath is a
433
+ * shared cache and is left alone. Scoped parents are pruned when they become
434
+ * empty.
435
+ * @param profileDir - the profile directory.
436
+ * @param packageNames - npm package names to remove from node_modules.
437
+ * @returns the names that were actually removed.
438
+ */
439
+ export function reconcileNodeModules(profileDir, packageNames) {
440
+ const removed = [];
441
+ for (const raw of Array.isArray(packageNames) ? packageNames : []) {
442
+ const name = String(raw ?? "").trim();
443
+ if (name.length === 0 || !NPM_PACKAGE_NAME_RE.test(name)) continue;
444
+ const parts = name.split("/");
445
+ const target = join(profileDir, "node_modules", ...parts);
446
+ if (existsSync(target)) {
447
+ // rm, never cp-overwrite: pnpm hard-links into its global store, and a
448
+ // later pnpm add/remove rebuilds the tree from the lockfile anyway.
449
+ rmSync(target, { recursive: true, force: true });
450
+ removed.push(name);
451
+ }
452
+ if (parts.length === 2) {
453
+ const scope = join(profileDir, "node_modules", parts[0]);
454
+ try {
455
+ if (existsSync(scope) && readdirSync(scope).length === 0) rmSync(scope, { recursive: true, force: true });
456
+ } catch {
457
+ /* non-empty or unreadable scope — leave it */
458
+ }
459
+ }
460
+ }
461
+ return removed;
462
+ }
463
+
464
+ /** Inspect one already materialized candidate package against a profile. */
465
+ export function inspectCandidate({ profileDir, candidateManifestPath, spec }) {
466
+ const issues = [];
467
+ const candidate = { manifestPath: candidateManifestPath, dir: dirname(candidateManifestPath), manifest: readJson(candidateManifestPath) };
468
+ const candidateName = String(candidate.manifest.name ?? "").trim();
469
+ if (candidateName.length === 0) {
470
+ issues.push(issue("block", "package-name-missing", "插件缺少包名", `${spec} 的 package.json 没有有效的 name。`));
471
+ }
472
+ const currentIssues = [];
473
+ const current = installedProfile(profileDir, currentIssues);
474
+ // Existing profile defects are reported as warnings unless the candidate
475
+ // directly collides with them; an unrelated historical issue should not
476
+ // make every future install impossible.
477
+ issues.push(...currentIssues.map((entry) => ({ ...entry, severity: "warn", code: `existing-${entry.code}` })));
478
+ issues.push(...compatibilityIssues(candidate, current, profileDir));
479
+
480
+ let rows = rowsForPackage(candidate, issues, "candidate");
481
+ const kind = typeof candidate.manifest.dsh?.bundle?.patch === "string"
482
+ ? "bundle"
483
+ : candidate.manifest.dsh?.client !== undefined ? "client" : "plain";
484
+ if (kind === "client" && candidateName.length > 0) {
485
+ rows = [{ id: clientRowId(candidateName), name: candidateName, source: "candidate-client", owner: candidateName }];
486
+ }
487
+ if (kind === "plain") {
488
+ issues.push(issue("warn", "not-a-plugin", "该包没有声明 DSH 插件入口", `${candidateName || spec} 没有 dsh.bundle.patch 或 dsh.client,安装后只是普通依赖。`, { package: candidateName || undefined }));
489
+ }
490
+ const existingRows = current.rows.filter((row) => row.owner !== candidateName);
491
+ issues.push(...rowConflictIssues(candidateName, rows, existingRows));
492
+
493
+ // UI replacements historically predate exclusiveGroups. Keep the
494
+ // heuristic advisory-only to avoid blocking legitimate sidebar extensions.
495
+ if (/sidebar/i.test(candidateName) && current.dependencies.some((name) => name !== candidateName && /sidebar/i.test(name))) {
496
+ const other = current.dependencies.find((name) => name !== candidateName && /sidebar/i.test(name));
497
+ issues.push(issue("warn", "sidebar-overlap", "可能存在侧边栏插件重叠", `${candidateName} 与已安装的 ${other} 都像是侧边栏插件,请确认两者能共存。`, { package: candidateName, conflictsWith: [other] }));
498
+ }
499
+
500
+ const blockers = issues.filter((entry) => entry.severity === "block");
501
+ const warnings = issues.filter((entry) => entry.severity === "warn");
502
+ const verdict = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "safe";
503
+ return {
504
+ ok: blockers.length === 0,
505
+ verdict,
506
+ candidate: {
507
+ name: candidateName || undefined,
508
+ version: candidate.manifest.version,
509
+ kind,
510
+ rows: rows.map(({ id, name }) => ({ id, name })),
511
+ },
512
+ issues,
513
+ summary: blockers.length > 0
514
+ ? `发现 ${blockers.length} 个阻断问题、${warnings.length} 个警告`
515
+ : warnings.length > 0 ? `未发现阻断问题,但有 ${warnings.length} 个警告` : "未发现已知冲突",
516
+ };
517
+ }
518
+
519
+ /**
520
+ * Browsing-time conflict scan for a plugin that has NOT been downloaded: the
521
+ * manifest (and, for bundle plugins, the patch text) come straight from the
522
+ * repo's raw files. It reuses the same static checks as inspectCandidate —
523
+ * host-module shadowing, peer/Node/OS ranges, declared conflicts, exclusive
524
+ * groups, loader-row collisions — so the marketplace can badge a card before
525
+ * the user ever clicks install. A bundle whose patch could not be fetched gets
526
+ * a `patch-unverified` warning instead of fabricated rows: loader-collision
527
+ * checks are then simply not performed, and the badge must say so.
528
+ */
529
+ export function inspectRemoteCandidate({ profileDir, manifest, patchText, spec }) {
530
+ const issues = [];
531
+ const candidate = { manifestPath: undefined, dir: undefined, manifest };
532
+ const candidateName = String(manifest?.name ?? "").trim();
533
+ if (candidateName.length === 0) {
534
+ issues.push(issue("block", "package-name-missing", "插件缺少包名", `${spec} 的 package.json 没有有效的 name。`));
535
+ }
536
+ const currentIssues = [];
537
+ const current = installedProfile(profileDir, currentIssues);
538
+ issues.push(...currentIssues.map((entry) => ({ ...entry, severity: "warn", code: `existing-${entry.code}` })));
539
+ issues.push(...compatibilityIssues(candidate, current, profileDir));
540
+
541
+ const kind = typeof manifest?.dsh?.bundle?.patch === "string"
542
+ ? "bundle"
543
+ : manifest?.dsh?.client !== undefined ? "client" : "plain";
544
+ let rows = [];
545
+ if (kind === "bundle") {
546
+ if (patchText === undefined) {
547
+ issues.push(issue("warn", "patch-unverified", "补丁未获取,加载冲突未检查", `${candidateName || spec} 声明了 ${manifest.dsh.bundle.patch},但浏览时未能获取该文件;加载 ID 冲突要在安装预检时才会验证。`, { package: candidateName || undefined }));
548
+ } else {
549
+ rows = parsePatchText(patchText, "candidate", issues, candidateName || spec);
550
+ }
551
+ }
552
+ if (kind === "client" && candidateName.length > 0) {
553
+ rows = [{ id: clientRowId(candidateName), name: candidateName, source: "candidate-client", owner: candidateName }];
554
+ }
555
+ if (kind === "plain") {
556
+ issues.push(issue("warn", "not-a-plugin", "该包没有声明 DSH 插件入口", `${candidateName || spec} 没有 dsh.bundle.patch 或 dsh.client,安装后只是普通依赖。`, { package: candidateName || undefined }));
557
+ }
558
+ const existingRows = current.rows.filter((row) => row.owner !== candidateName);
559
+ issues.push(...rowConflictIssues(candidateName, rows, existingRows));
560
+
561
+ if (/sidebar/i.test(candidateName) && current.dependencies.some((name) => name !== candidateName && /sidebar/i.test(name))) {
562
+ const other = current.dependencies.find((name) => name !== candidateName && /sidebar/i.test(name));
563
+ issues.push(issue("warn", "sidebar-overlap", "可能存在侧边栏插件重叠", `${candidateName} 与已安装的 ${other} 都像是侧边栏插件,请确认两者能共存。`, { package: candidateName, conflictsWith: [other] }));
564
+ }
565
+
566
+ const blockers = issues.filter((entry) => entry.severity === "block");
567
+ const warnings = issues.filter((entry) => entry.severity === "warn");
568
+ const verdict = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "safe";
569
+ return {
570
+ ok: blockers.length === 0,
571
+ verdict,
572
+ candidate: {
573
+ name: candidateName || undefined,
574
+ version: manifest?.version,
575
+ kind,
576
+ rows: rows.map(({ id, name }) => ({ id, name })),
577
+ },
578
+ issues,
579
+ summary: blockers.length > 0
580
+ ? `发现 ${blockers.length} 个阻断问题、${warnings.length} 个警告`
581
+ : warnings.length > 0 ? `未发现阻断问题,但有 ${warnings.length} 个警告` : "未发现已知冲突",
582
+ };
583
+ }
584
+
585
+ /** Locate a binary on PATH by explicit extension (no shell, no PATHEXT guessing). */
586
+ function findOnPath(binary, extensions) {
587
+ const separator = process.platform === "win32" ? ";" : ":";
588
+ for (const dir of String(process.env.PATH ?? "").split(separator)) {
589
+ if (dir.length === 0) continue;
590
+ for (const ext of extensions) {
591
+ const candidate = join(dir, `${binary}${ext}`);
592
+ if (existsSync(candidate)) return candidate;
593
+ }
594
+ }
595
+ return undefined;
596
+ }
597
+
598
+ /**
599
+ * pnpm spawn plan, mirroring installer.js pnpmSpawnPlan: a real .exe spawns
600
+ * without a shell; only the .cmd shim forces a cmd wrapper on Windows
601
+ * (shell:true, and Node's DEP0190 warning with it).
602
+ */
603
+ function pnpmSpawnPlan() {
604
+ if (process.platform !== "win32") return { command: "pnpm", shell: false };
605
+ const exe = findOnPath("pnpm", [".exe"]);
606
+ if (exe !== undefined) return { command: exe, shell: false };
607
+ const cmd = findOnPath("pnpm", [".cmd"]);
608
+ return { command: cmd ?? "pnpm", shell: true };
609
+ }
610
+
611
+ function spawnCapture(command, args, options, onOutput) {
612
+ return new Promise((resolvePromise) => {
613
+ let child;
614
+ const chunks = [];
615
+ const push = (value) => {
616
+ const text = value.toString();
617
+ chunks.push(text);
618
+ onOutput?.(text);
619
+ };
620
+ try {
621
+ child = spawn(command, args, {
622
+ ...options,
623
+ shell: options.shell === true,
624
+ stdio: ["ignore", "pipe", "pipe"],
625
+ windowsHide: true,
626
+ });
627
+ } catch (error) {
628
+ resolvePromise({ exitCode: 1, output: "", error });
629
+ return;
630
+ }
631
+ child.stdout?.on("data", push);
632
+ child.stderr?.on("data", push);
633
+ child.on("error", (error) => resolvePromise({ exitCode: 1, output: chunks.join(""), error }));
634
+ child.on("close", (exitCode) => resolvePromise({ exitCode: exitCode ?? 1, output: chunks.join("") }));
635
+ });
636
+ }
637
+
638
+ // The install spec is eventually handed to `pnpm add` — through a cmd shell on
639
+ // Windows, where Node joins the args with spaces and does not per-argument
640
+ // quote. It must therefore be validated at the exported preflight boundary too,
641
+ // not only by the agent/browser callers that happen to check first: anything
642
+ // carrying shell metacharacters, or a Windows `file:`/`link:` path with spaces,
643
+ // is refused before a single byte of filesystem work and before pnpm is spawned.
644
+ const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
645
+
646
+ function assertSafeSpec(spec) {
647
+ const value = String(spec ?? "");
648
+ if (UNSAFE_SPEC_RE.test(value)) {
649
+ throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(value)}`);
650
+ }
651
+ if (process.platform === "win32" && /^(?:file:|link:)/i.test(value) && /\s/.test(value)) {
652
+ throw new Error(`local path specs cannot contain spaces on Windows — pnpm is spawned through cmd, which would split the path into two arguments: ${JSON.stringify(value)}`);
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Args for the disposable probe install. Peer auto-install is disabled so the
658
+ * probe resolves only the candidate itself: host peers (@deepseek-ai/*) must
659
+ * stay unsatisfied — compatibility with them is decided by inspectCandidate
660
+ * against the live profile, not by auto-installing the host into the probe.
661
+ * Lifecycle scripts stay disabled.
662
+ */
663
+ function probeAddArgs(spec) {
664
+ return ["add", spec, "--ignore-scripts", "--config.auto-install-peers=false", "--reporter=append-only"];
665
+ }
666
+
667
+ /**
668
+ * Install a candidate into a disposable directory with every lifecycle script
669
+ * disabled, then inspect its actual package manifest and patch files.
670
+ */
671
+ export async function preflightInstall({ profileDir, spec, onOutput }) {
672
+ try {
673
+ assertSafeSpec(spec);
674
+ } catch (error) {
675
+ return {
676
+ ok: false,
677
+ verdict: "blocked",
678
+ candidate: { name: undefined, version: undefined, kind: "unknown", rows: [] },
679
+ issues: [issue("block", "unsafe-spec", "安装 spec 不安全", error.message)],
680
+ summary: "安装 spec 未通过安全校验,未执行任何安装",
681
+ };
682
+ }
683
+ const probeDir = mkdtempSync(join(tmpdir(), "dsh-plugin-guard-"));
684
+ try {
685
+ writeFileSync(join(probeDir, "package.json"), JSON.stringify({ name: "dsh-plugin-guard-probe", private: true }, undefined, 2) + "\n");
686
+ writeFileSync(join(probeDir, "pnpm-workspace.yaml"), "packages:\n - .\n\nnodeLinker: hoisted\n");
687
+ // Reuse the profile's registry/auth settings for the probe. The file may
688
+ // hold credentials, so it is copied (never logged) and removed by the
689
+ // finally cleanup below together with the rest of the probe directory.
690
+ const profileNpmrc = join(profileDir, ".npmrc");
691
+ if (existsSync(profileNpmrc)) copyFileSync(profileNpmrc, join(probeDir, ".npmrc"));
692
+ onOutput?.(`[dsh-plugin-guard] probing ${spec} with install scripts disabled\n`);
693
+ const plan = pnpmSpawnPlan();
694
+ const result = await spawnCapture(plan.command, probeAddArgs(spec), { cwd: probeDir, env: process.env, shell: plan.shell }, onOutput);
695
+ if (result.exitCode !== 0) {
696
+ return {
697
+ ok: false,
698
+ verdict: "blocked",
699
+ candidate: { name: undefined, version: undefined, kind: "unknown", rows: [] },
700
+ issues: [issue("block", "probe-install-failed", "无法在隔离环境解析插件", `pnpm 隔离安装失败(退出码 ${result.exitCode}):${result.output.replace(/\s+/g, " ").trim().slice(-600) || result.error?.message || "无输出"}`)],
701
+ summary: "隔离安装失败,正式 profile 未被修改",
702
+ };
703
+ }
704
+ const probeManifest = readJson(join(probeDir, "package.json"));
705
+ const names = Object.keys(probeManifest.dependencies ?? {});
706
+ if (names.length !== 1) {
707
+ return {
708
+ ok: false,
709
+ verdict: "blocked",
710
+ candidate: { name: undefined, version: undefined, kind: "unknown", rows: [] },
711
+ issues: [issue("block", "probe-ambiguous", "无法确定候选插件", `隔离安装后发现 ${names.length} 个直接依赖,预期为 1 个。`)],
712
+ summary: "无法确定候选插件,正式 profile 未被修改",
713
+ };
714
+ }
715
+ const candidatePath = packageJsonPathOf(names[0], probeDir);
716
+ if (candidatePath === undefined) throw new Error(`installed package ${names[0]} has no resolvable package.json`);
717
+ return inspectCandidate({ profileDir, candidateManifestPath: candidatePath, spec });
718
+ } catch (error) {
719
+ return {
720
+ ok: false,
721
+ verdict: "blocked",
722
+ candidate: { name: undefined, version: undefined, kind: "unknown", rows: [] },
723
+ issues: [issue("block", "preflight-error", "预检执行失败", error.message)],
724
+ summary: "预检执行失败,正式 profile 未被修改",
725
+ };
726
+ } finally {
727
+ // probeDir is created by mkdtemp directly under the system temp folder;
728
+ // it never contains user-authored files.
729
+ rmSync(probeDir, { recursive: true, force: true });
730
+ }
731
+ }
732
+
733
+ function guardHome(profileDir) {
734
+ return join(dirname(dirname(profileDir)), "guard");
735
+ }
736
+
737
+ function snapshotRoot(profileDir) {
738
+ return join(guardHome(profileDir), "snapshots");
739
+ }
740
+
741
+ function pendingPath(profileDir) {
742
+ return join(guardHome(profileDir), `pending-${basename(profileDir)}.json`);
743
+ }
744
+
745
+ function samePath(a, b) {
746
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
747
+ }
748
+
749
+ /** Validate the transaction discriminator and identities carried by a marker.
750
+ * `operation` is deliberately duplicated at top level and in immutable
751
+ * snapshot metadata: disagreement is corruption, never a reason to guess.
752
+ */
753
+ function validatePendingTransaction(marker, { requireCandidate = true } = {}) {
754
+ if (marker.metadata === null || typeof marker.metadata !== "object" || Array.isArray(marker.metadata)) {
755
+ throw new Error("pending marker metadata is not an object — refusing to act on it (left untouched for manual inspection)");
756
+ }
757
+ const operation = marker.operation;
758
+ const metadataOperation = marker.metadata.operation;
759
+ if (!PENDING_OPERATIONS.has(operation) || !PENDING_OPERATIONS.has(metadataOperation)) {
760
+ throw new Error(`pending marker has an unsupported operation ${JSON.stringify(operation)} / metadata.operation ${JSON.stringify(metadataOperation)} — refusing to act on it (left untouched for manual inspection)`);
761
+ }
762
+ if (operation !== metadataOperation) {
763
+ throw new Error("pending marker top-level operation does not match metadata.operation — refusing to act on it (left untouched for manual inspection)");
764
+ }
765
+
766
+ const identities = [marker.preflight?.candidate?.name, marker.candidate?.name]
767
+ .filter((value) => value !== undefined);
768
+ if (requireCandidate) {
769
+ if (identities.length === 0 || identities.some((name) => typeof name !== "string" || !NPM_PACKAGE_NAME_RE.test(name))) {
770
+ throw new Error("pending marker does not identify a valid candidate package — refusing to act on it (left untouched for manual inspection)");
771
+ }
772
+ if (identities.some((name) => name !== identities[0])) {
773
+ throw new Error("pending marker candidate identities disagree — refusing to act on it (left untouched for manual inspection)");
774
+ }
775
+ }
776
+
777
+ if (operation === "remove") {
778
+ const packageName = marker.metadata.packageName;
779
+ if (typeof packageName !== "string" || !NPM_PACKAGE_NAME_RE.test(packageName)) {
780
+ throw new Error("pending remove marker metadata.packageName is invalid — refusing to act on it (left untouched for manual inspection)");
781
+ }
782
+ if (requireCandidate && identities[0] !== packageName) {
783
+ throw new Error("pending remove marker candidate does not match metadata.packageName — refusing to act on it (left untouched for manual inspection)");
784
+ }
785
+ } else if (marker.metadata.packageName !== undefined) {
786
+ throw new Error("pending install marker unexpectedly carries metadata.packageName — refusing to act on it (left untouched for manual inspection)");
787
+ }
788
+ return operation;
789
+ }
790
+
791
+ /** The marker may be attacker-edited, so compare its transaction identity to
792
+ * snapshot.json, which was written before the profile mutation began. */
793
+ function assertStoredTransactionMatches(pending) {
794
+ const snapshotPath = join(pending.dir, "snapshot.json");
795
+ let stored;
796
+ try {
797
+ stored = readJson(snapshotPath);
798
+ } catch (error) {
799
+ throw new Error(`pending snapshot transaction metadata is missing or invalid — refusing to act on it (left untouched for manual inspection): ${error.message}`);
800
+ }
801
+ if (
802
+ stored?.version !== pending.version
803
+ || stored?.id !== pending.id
804
+ || !samePath(resolve(String(stored?.profileDir ?? "")), pending.profileDir)
805
+ ) {
806
+ throw new Error("pending marker identity does not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
807
+ }
808
+ const storedOperation = validatePendingTransaction(stored, { requireCandidate: false });
809
+ if (storedOperation !== pending.operation) {
810
+ throw new Error("pending marker operation does not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
811
+ }
812
+ if (pending.operation === "remove" && stored.metadata.packageName !== pending.metadata.packageName) {
813
+ throw new Error("pending remove packageName does not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Validate an on-disk pending marker and return a sanitized snapshot. The
819
+ * marker is attacker-controllable JSON, so none of its fields may drive a
820
+ * filesystem operation until proven well-formed and confined: version and file
821
+ * metadata must match the snapshot format, id must be a strict safe token, and
822
+ * profileDir must resolve to a direct child of <home>/profiles. `dir` is
823
+ * recomputed from the validated profileDir + id and never taken from the
824
+ * marker. Since v2 the rollback metadata is validated just as strictly:
825
+ * `dependencies` must be a list of valid package names (the original direct
826
+ * dependency state) and the candidate identity (`preflight.candidate.name` or
827
+ * `candidate.name`) must be a valid package name — a rollback that can name
828
+ * neither would restore the manifest, prune node_modules, and then clear the
829
+ * only recovery evidence. Throws on any invalid marker so the caller leaves
830
+ * it untouched.
831
+ */
832
+ function sanitizeSnapshot(marker, home) {
833
+ if (marker === null || typeof marker !== "object") {
834
+ throw new Error("pending marker is not a snapshot object — refusing to act on it (left untouched for manual inspection)");
835
+ }
836
+ if (marker.version !== SNAPSHOT_VERSION) {
837
+ throw new Error(`pending marker has unsupported version ${JSON.stringify(marker.version)} — refusing to act on it (left untouched for manual inspection)`);
838
+ }
839
+ if (typeof marker.id !== "string" || !SNAPSHOT_ID_RE.test(marker.id)) {
840
+ throw new Error(`pending marker has an invalid snapshot id ${JSON.stringify(marker.id)} — refusing to act on it (left untouched for manual inspection)`);
841
+ }
842
+ if (marker.files === null || typeof marker.files !== "object") {
843
+ throw new Error("pending marker is missing snapshot file metadata — refusing to act on it (left untouched for manual inspection)");
844
+ }
845
+ for (const name of PROFILE_FILES) {
846
+ const entry = marker.files?.[name];
847
+ if (entry === null || typeof entry !== "object" || typeof entry.present !== "boolean") {
848
+ throw new Error(`pending marker is missing file metadata for ${name} — refusing to act on it (left untouched for manual inspection)`);
849
+ }
850
+ }
851
+ if (!Array.isArray(marker.dependencies) || marker.dependencies.some((name) => typeof name !== "string" || !NPM_PACKAGE_NAME_RE.test(name))) {
852
+ throw new Error("pending marker has missing or corrupt dependency metadata — refusing to act on it (left untouched for manual inspection)");
853
+ }
854
+ validatePendingTransaction(marker);
855
+ const profileDir = resolve(String(marker.profileDir ?? ""));
856
+ const profilesRoot = resolve(join(home, "profiles"));
857
+ if (!samePath(dirname(profileDir), profilesRoot)) {
858
+ throw new Error(`pending marker profileDir ${JSON.stringify(marker.profileDir)} is not a direct child of ${profilesRoot} — refusing to act on it (left untouched for manual inspection)`);
859
+ }
860
+ const dir = join(snapshotRoot(profileDir), marker.id);
861
+ return { ...marker, profileDir, dir };
862
+ }
863
+
864
+ /** Read and validate a profile's pending marker; undefined when none exists. */
865
+ function readValidatedPendingSnapshot(profileDir) {
866
+ const resolved = resolve(profileDir);
867
+ const filePath = pendingPath(resolved);
868
+ if (!existsSync(filePath)) return undefined;
869
+ let marker;
870
+ try {
871
+ marker = readJson(filePath);
872
+ } catch (error) {
873
+ throw new Error(`pending marker ${filePath} is not valid JSON — refusing to act on it (left untouched for manual inspection): ${error.message}`);
874
+ }
875
+ const sanitized = sanitizeSnapshot(marker, dirname(dirname(resolved)));
876
+ if (!samePath(sanitized.profileDir, resolved)) {
877
+ throw new Error(`pending marker profileDir ${JSON.stringify(marker.profileDir)} does not match its marker location ${resolved} — refusing to act on it (left untouched for manual inspection)`);
878
+ }
879
+ assertStoredTransactionMatches(sanitized);
880
+ return sanitized;
881
+ }
882
+
883
+ /** Save the files that determine which plugins pnpm installs and dsh loads. */
884
+ export function createProfileSnapshot(profileDir, metadata = {}) {
885
+ const normalizedMetadata = { ...(metadata ?? {}) };
886
+ normalizedMetadata.operation ??= "install";
887
+ const operationProbe = { operation: normalizedMetadata.operation, metadata: normalizedMetadata };
888
+ validatePendingTransaction(operationProbe, { requireCandidate: false });
889
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
890
+ const dir = join(snapshotRoot(profileDir), id);
891
+ mkdirSync(dir, { recursive: true });
892
+ const files = {};
893
+ for (const name of PROFILE_FILES) {
894
+ const source = join(profileDir, name);
895
+ const present = existsSync(source);
896
+ files[name] = { present };
897
+ if (present) copyFileSync(source, join(dir, name));
898
+ }
899
+ // Record the direct dependency keys so a later rollback knows which
900
+ // node_modules entries the failed install added (and can remove them).
901
+ let dependencies = [];
902
+ try {
903
+ dependencies = Object.keys(readJson(join(profileDir, "package.json")).dependencies ?? {});
904
+ } catch {
905
+ /* manifest unreadable — the rest of the snapshot still captures the bytes */
906
+ }
907
+ const snapshot = { version: SNAPSHOT_VERSION, id, dir, profileDir, createdAt: Date.now(), files, dependencies, operation: normalizedMetadata.operation, metadata: normalizedMetadata };
908
+ writeFileSync(join(dir, "snapshot.json"), JSON.stringify(snapshot, undefined, 2) + "\n");
909
+ return snapshot;
910
+ }
911
+
912
+ /** Restore snapshot bytes. Extra node_modules are harmless once unmounted. */
913
+ export function restoreProfileSnapshot(snapshot) {
914
+ // Never trust snapshot.dir/profileDir from an untrusted source: re-derive the
915
+ // home, re-validate the record, and recompute `dir` before any copy/remove,
916
+ // then prove each resolved path stays within its validated root.
917
+ const home = dirname(dirname(resolve(String(snapshot?.profileDir ?? ""))));
918
+ const validated = sanitizeSnapshot(snapshot, home);
919
+ if (!existsSync(validated.dir)) throw new Error("invalid or missing dsh guard snapshot");
920
+ for (const name of PROFILE_FILES) {
921
+ const source = join(validated.dir, name);
922
+ const target = join(validated.profileDir, name);
923
+ if (!samePath(dirname(source), validated.dir) || !samePath(dirname(target), validated.profileDir)) {
924
+ throw new Error(`snapshot restore path for ${name} escapes its root — refusing to restore (left untouched for manual inspection)`);
925
+ }
926
+ if (validated.files?.[name]?.present === true) copyFileSync(source, target);
927
+ else rmSync(target, { force: true });
928
+ }
929
+ }
930
+
931
+ export function markPendingSnapshot(snapshot, record = {}) {
932
+ // A pending marker is a one-shot transaction: it must NEVER be superseded.
933
+ // If anything is already pending for this profile — a valid marker or a
934
+ // corrupt one that the recovery path still needs to see — refuse instead of
935
+ // overwriting it, so the old marker and its snapshot survive for manual
936
+ // inspection / recovery. The check is existence-only (deliberately not
937
+ // readValidatedPendingSnapshot): a corrupt marker must block a new install
938
+ // just as hard as a valid one, and throwing on the read would only lose the
939
+ // "why" for the caller.
940
+ const markerPath = pendingPath(snapshot.profileDir);
941
+ if (existsSync(markerPath)) {
942
+ throw new Error(`profile already has a pending install marker at ${markerPath} — run \`dsh-plugin-guard guard recover\` (or let dsh startup recovery consume it) before installing again`);
943
+ }
944
+ const pending = { ...snapshot, ...record, pendingAt: Date.now() };
945
+ // Reject producer bugs before persisting them. The read/recovery boundary
946
+ // repeats this validation because the marker is attacker-controllable.
947
+ validatePendingTransaction(pending);
948
+ mkdirSync(guardHome(snapshot.profileDir), { recursive: true });
949
+ writeFileSync(markerPath, JSON.stringify(pending, undefined, 2) + "\n");
950
+ return pending;
951
+ }
952
+
953
+ export function readPendingSnapshot(profileDir) {
954
+ const filePath = pendingPath(profileDir);
955
+ if (!existsSync(filePath)) return undefined;
956
+ try {
957
+ return readJson(filePath);
958
+ } catch {
959
+ return undefined;
960
+ }
961
+ }
962
+
963
+ export function commitPendingSnapshot(profileDir) {
964
+ const pending = readValidatedPendingSnapshot(profileDir);
965
+ if (pending === undefined) return undefined;
966
+ rmSync(pendingPath(profileDir), { force: true });
967
+ rmSync(pending.dir, { recursive: true, force: true });
968
+ return pending;
969
+ }
970
+
971
+ /** Names this pending install added as direct dependencies (for node_modules cleanup). */
972
+ function addedDependencyNames(pending, originalDependencies = pending?.dependencies) {
973
+ const names = new Set();
974
+ const candidate = pending?.preflight?.candidate?.name ?? pending?.candidate?.name;
975
+ if (typeof candidate === "string" && candidate.length > 0) names.add(candidate);
976
+ try {
977
+ const current = Object.keys(readJson(join(pending.profileDir, "package.json")).dependencies ?? {});
978
+ const before = new Set(originalDependencies ?? []);
979
+ for (const name of current) if (!before.has(name)) names.add(name);
980
+ } catch {
981
+ /* manifest unreadable — the candidate name (if any) still covers the common case */
982
+ }
983
+ return [...names];
984
+ }
985
+
986
+ // ── rollback node_modules rebuild ────────────────────────────────────────────
987
+ //
988
+ // `pnpm add` swaps node_modules/<name> in place, so when an interrupted UPDATE
989
+ // is rolled back, deleting the candidate's entry also deletes the old version
990
+ // that used to live there. Restoring manifest + lockfile is then only half the
991
+ // job: the tree must be rebuilt from the restored lockfile, or the profile is
992
+ // left declaring a dependency nothing provides.
993
+
994
+ /**
995
+ * Env for any pnpm the guard (or its callers) spawns: peer auto-install stays
996
+ * disabled so an install/reconcile never pulls the @deepseek-ai host peer
997
+ * stack into the profile. Both spellings are set: npm/pnpm read the lowercase
998
+ * npm_config_* form, the uppercase form covers case-sensitive consumers.
999
+ */
1000
+ export function pnpmGuardEnv(base = process.env) {
1001
+ return {
1002
+ ...base,
1003
+ npm_config_auto_install_peers: "false",
1004
+ NPM_CONFIG_AUTO_INSTALL_PEERS: "false",
1005
+ };
1006
+ }
1007
+
1008
+ /**
1009
+ * Args for the lockfile-driven node_modules rebuild after a rollback. Fixed
1010
+ * strings only — nothing user-controlled ever reaches this argv (or a shell
1011
+ * line): lifecycle scripts stay disabled, the restored lockfile is
1012
+ * authoritative, peer auto-install stays off, and the install is strictly
1013
+ * offline. There is deliberately no online mode: rollback is a recovery path
1014
+ * and must never depend on (or hang on) the network.
1015
+ */
1016
+ function reconcileInstallArgs() {
1017
+ return [
1018
+ "install",
1019
+ "--ignore-scripts",
1020
+ "--frozen-lockfile",
1021
+ "--config.auto-install-peers=false",
1022
+ "--reporter=append-only",
1023
+ "--offline",
1024
+ ];
1025
+ }
1026
+
1027
+ /**
1028
+ * Run the offline reconcile install synchronously (rollback is a sync
1029
+ * recovery path, called from CLI/startup contexts that cannot await). Never
1030
+ * throws: a missing/broken pnpm is reported like a nonzero exit.
1031
+ */
1032
+ function runReconcileInstall(profileDir) {
1033
+ let result;
1034
+ try {
1035
+ const plan = pnpmSpawnPlan();
1036
+ result = spawnSync(plan.command, reconcileInstallArgs(), {
1037
+ cwd: profileDir,
1038
+ env: pnpmGuardEnv(),
1039
+ shell: plan.shell,
1040
+ encoding: "utf8",
1041
+ timeout: 180000,
1042
+ windowsHide: true,
1043
+ });
1044
+ } catch (error) {
1045
+ return { exitCode: 1, output: "", error };
1046
+ }
1047
+ return {
1048
+ exitCode: typeof result.status === "number" ? result.status : 1,
1049
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
1050
+ error: result.error,
1051
+ };
1052
+ }
1053
+
1054
+ /**
1055
+ * Whether the profile's OWN `node_modules/<name>/package.json` provides the
1056
+ * candidate at a version the restored dependency spec accepts. pnpm can exit
1057
+ * nonzero because of an UNRELATED package missing from the offline store even
1058
+ * after it already relinked the package the rollback actually targets — this
1059
+ * check tells "target restored, collateral failure" apart from "target still
1060
+ * gone". Only the DIRECT package counts: Node resolution walks up into
1061
+ * ancestor node_modules and could "find" a satisfying copy the profile does
1062
+ * not actually provide, so the path is built by hand, verified to stay inside
1063
+ * the profile's node_modules, and the manifest's `name` must match exactly.
1064
+ * Non-semver specs (github:, file:, …) cannot be version-checked; a present
1065
+ * direct package is the best assertion available for them. A missing or
1066
+ * mismatched direct package returns false so the caller KEEPS the pending
1067
+ * marker + snapshot and throws.
1068
+ */
1069
+ function candidateRestoredCompatible(profileDir, name, spec) {
1070
+ if (typeof name !== "string" || !NPM_PACKAGE_NAME_RE.test(name)) return false;
1071
+ const modulesRoot = resolve(profileDir, "node_modules");
1072
+ const manifestPath = resolve(modulesRoot, ...name.split("/"), "package.json");
1073
+ const rel = relative(modulesRoot, manifestPath);
1074
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return false;
1075
+ let manifest;
1076
+ try {
1077
+ manifest = readJson(manifestPath);
1078
+ } catch {
1079
+ return false; // no direct package (or an unreadable one) — keep the marker
1080
+ }
1081
+ if (manifest?.name !== name) return false;
1082
+ const version = manifest?.version;
1083
+ if (typeof version !== "string" || version.length === 0) return false;
1084
+ const range = String(spec ?? "").trim();
1085
+ if (range.length === 0 || validRange(range) === null) return true;
1086
+ try {
1087
+ return satisfies(version, range, { includePrerelease: true, loose: true });
1088
+ } catch {
1089
+ return false;
1090
+ }
1091
+ }
1092
+
1093
+ export function rollbackPendingSnapshot(profileDir) {
1094
+ const pending = readValidatedPendingSnapshot(profileDir);
1095
+ if (pending === undefined) return undefined;
1096
+
1097
+ // Derive original dependencies from the protected snapshot package.json bytes.
1098
+ // A syntactically valid but dishonest dependency list in marker must fail closed
1099
+ // before restore/removal, retaining marker and snapshot.
1100
+ let originalDependencies;
1101
+ const snapManifestEntry = pending.files?.["package.json"];
1102
+ if (snapManifestEntry?.present === true) {
1103
+ const snapManifestPath = join(pending.dir, "package.json");
1104
+ if (!existsSync(snapManifestPath)) {
1105
+ throw new Error("pending snapshot package.json is missing — refusing to act on it (left untouched for manual inspection)");
1106
+ }
1107
+ let snapManifest;
1108
+ try {
1109
+ snapManifest = readJson(snapManifestPath);
1110
+ } catch (error) {
1111
+ throw new Error(`pending snapshot package.json is invalid JSON — refusing to act on it (left untouched for manual inspection): ${error.message}`);
1112
+ }
1113
+ if (snapManifest === null || typeof snapManifest !== "object") {
1114
+ throw new Error("pending snapshot package.json is not an object — refusing to act on it (left untouched for manual inspection)");
1115
+ }
1116
+ const snapDeps = snapManifest.dependencies ?? {};
1117
+ if (typeof snapDeps !== "object" || Array.isArray(snapDeps) || snapDeps === null) {
1118
+ throw new Error("pending snapshot package.json dependencies is not an object — refusing to act on it (left untouched for manual inspection)");
1119
+ }
1120
+ const snapDepKeys = Object.keys(snapDeps);
1121
+ const markerDeps = pending.dependencies;
1122
+ if (!Array.isArray(markerDeps)) {
1123
+ throw new Error("pending marker dependencies is not an array — refusing to act on it (left untouched for manual inspection)");
1124
+ }
1125
+ const snapSet = new Set(snapDepKeys);
1126
+ const markerSet = new Set(markerDeps);
1127
+ if (snapSet.size !== markerSet.size || snapDepKeys.some((k) => !markerSet.has(k)) || markerDeps.some((k) => !snapSet.has(k))) {
1128
+ throw new Error("pending marker dependencies do not match snapshot package.json — refusing to act on it (left untouched for manual inspection)");
1129
+ }
1130
+ originalDependencies = snapDepKeys;
1131
+ } else {
1132
+ if (Array.isArray(pending.dependencies) && pending.dependencies.length > 0) {
1133
+ throw new Error("pending marker declares dependencies but snapshot package.json was not present — refusing to act on it (left untouched for manual inspection)");
1134
+ }
1135
+ originalDependencies = [];
1136
+ }
1137
+
1138
+ const isRemove = pending.operation === "remove";
1139
+ // A remove transaction's candidate existed before the transaction. It must
1140
+ // never be treated as a newly-added package and deleted during rollback.
1141
+ const added = isRemove ? [] : addedDependencyNames(pending, originalDependencies);
1142
+ // sanitizeSnapshot guarantees a well-formed candidate identity, so this is
1143
+ // always a valid package name — never undefined, never a path fragment.
1144
+ const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
1145
+ restoreProfileSnapshot(pending);
1146
+ // A restored manifest must not be shadowed by the package the failed
1147
+ // install left in node_modules. Removing its symlink/entry is safe: the
1148
+ // shared `.pnpm` store stays put, and the reconcile below rebuilds the tree
1149
+ // from the restored lockfile.
1150
+ if (added.length > 0) reconcileNodeModules(profileDir, added);
1151
+ // Whether this rollback is an UPDATE is decided by the RESTORED manifest —
1152
+ // the bytes the snapshot just put back — never by marker.dependencies: a
1153
+ // tampered marker could empty that list to skip the rebuild below and
1154
+ // strand the profile without its old package. The restored manifest must
1155
+ // be readable with object-shaped dependencies; if it is not, fail closed
1156
+ // and KEEP the marker + snapshot as recovery evidence.
1157
+ let restoredDependencies;
1158
+ try {
1159
+ const restoredManifest = readJson(join(profileDir, "package.json"));
1160
+ const dependencies = restoredManifest.dependencies ?? {};
1161
+ if (typeof dependencies !== "object" || Array.isArray(dependencies) || dependencies === null) {
1162
+ throw new Error("dependencies is not an object");
1163
+ }
1164
+ restoredDependencies = dependencies;
1165
+ } catch (error) {
1166
+ throw new Error(
1167
+ `rollback restored the profile files and removed the failed install, but the restored manifest cannot be read (${error.message}) — the pending marker and snapshot were KEPT; re-run \`guard recover\` or repair ${profileDir} manually`
1168
+ );
1169
+ }
1170
+ // An update (the candidate name was already a dependency) means the removal
1171
+ // above also deletes the OLD copy of the package — it must come back.
1172
+ const wasUpdate = Object.prototype.hasOwnProperty.call(restoredDependencies, candidateName);
1173
+ // Rebuild node_modules from the restored lockfile so packages the
1174
+ // interrupted install displaced (the old version of an updated candidate)
1175
+ // are relinked. One offline attempt only — a recovery path must never
1176
+ // depend on the network or hang on it, so there is no online retry. A
1177
+ // newly added candidate (absent from the restored manifest) needs nothing
1178
+ // reinstalled: removing its node_modules entry above is sufficient.
1179
+ // Without a lockfile a frozen install can never succeed, so it is skipped.
1180
+ let attempt;
1181
+ if (isRemove) {
1182
+ // A failed/no-op remove commonly leaves the original direct package fully
1183
+ // intact. Accept that healthy copy without deleting it or invoking pnpm.
1184
+ // Only a genuinely missing/incompatible target needs the offline,
1185
+ // lockfile-driven rebuild.
1186
+ const restoredSpec = restoredDependencies[candidateName];
1187
+ const targetCompatible = wasUpdate && candidateRestoredCompatible(profileDir, candidateName, restoredSpec);
1188
+ if (!targetCompatible && pending.files?.["pnpm-lock.yaml"]?.present === true) {
1189
+ attempt = runReconcileInstall(profileDir);
1190
+ }
1191
+ } else if (wasUpdate && pending.files?.["pnpm-lock.yaml"]?.present === true) {
1192
+ attempt = runReconcileInstall(profileDir);
1193
+ }
1194
+
1195
+ // Before clearing marker/snapshot after rollback, strictly verify ALL
1196
+ // direct dependencies declared by the restored manifest exist in that
1197
+ // profile's own node_modules, have exact package names, and satisfy
1198
+ // semver/file/link requirements as far as can be safely checked.
1199
+ // Do not accept ancestor node_modules fallback. If offline reconcile exits
1200
+ // nonzero but every restored direct dependency is valid, accept; otherwise
1201
+ // throw and retain evidence.
1202
+ const unsatisfied = [];
1203
+ for (const [depName, spec] of Object.entries(restoredDependencies)) {
1204
+ if (!candidateRestoredCompatible(profileDir, depName, spec)) {
1205
+ unsatisfied.push(depName);
1206
+ }
1207
+ }
1208
+
1209
+ if (unsatisfied.length > 0) {
1210
+ const why =
1211
+ wasUpdate && pending.files?.["pnpm-lock.yaml"]?.present !== true
1212
+ ? "no pnpm-lock.yaml in the snapshot to rebuild from"
1213
+ : attempt?.error !== undefined
1214
+ ? attempt.error.message
1215
+ : attempt !== undefined
1216
+ ? `exit code ${attempt.exitCode}`
1217
+ : "node_modules missing direct dependency";
1218
+ const tail = String(attempt?.output ?? "").replace(/\s+/g, " ").trim().slice(-400);
1219
+ // KEEP the marker + snapshot: the profile files are already restored,
1220
+ // so a retry (`guard recover`) or a manual pnpm install finishes it.
1221
+ throw new Error(
1222
+ `rollback restored the profile files and removed the failed install, but direct dependencies in node_modules are missing or incompatible (${unsatisfied.join(", ")}; ${why}) — the pending marker and snapshot were KEPT; re-run \`guard recover\` or run \`pnpm install --ignore-scripts --frozen-lockfile\` in ${profileDir} manually${tail ? `. pnpm output: ${tail}` : ""}`
1223
+ );
1224
+ }
1225
+
1226
+ rmSync(pendingPath(profileDir), { force: true });
1227
+ rmSync(pending.dir, { recursive: true, force: true });
1228
+ return pending;
1229
+ }
1230
+
1231
+ // ── pending-snapshot recovery (startup + external CLI) ───────────────────────
1232
+ //
1233
+ // A successful install leaves a pending marker (snapshot + preflight report).
1234
+ // Until dsh has booted with the new plugin and the profile has been proven
1235
+ // loadable, the snapshot is the only safe fallback. `recoverProfile` consumes
1236
+ // the marker: it validates the profile as it sits on disk and either commits
1237
+ // (deletes the snapshot — the profile is fine) or rolls back (restores the
1238
+ // four profile files, drops the added package from node_modules, rebuilds the
1239
+ // tree from the restored lockfile with scripts disabled, deletes the marker).
1240
+ // A rollback whose lockfile rebuild leaves an updated package unrestorable
1241
+ // throws and KEEPS the marker for retry/manual repair instead of reporting a
1242
+ // clean rollback. The whole path is host-independent so it runs from a
1243
+ // standalone CLI even when dsh itself cannot boot.
1244
+
1245
+ /** Resolve the dsh home directory without importing the host framework. */
1246
+ export function resolveDshHome() {
1247
+ const env = process.env.DSH_HOME;
1248
+ const base = env !== undefined && String(env).trim().length > 0 ? env : join(homedir(), ".dsh");
1249
+ return resolve(base);
1250
+ }
1251
+
1252
+ /** The guard directory under a dsh home (`<home>/guard`). */
1253
+ export function guardDir(home = resolveDshHome()) {
1254
+ return join(home, "guard");
1255
+ }
1256
+
1257
+ /** List the pending markers currently on disk under a dsh home. */
1258
+ export function listPendingSnapshots(home = resolveDshHome()) {
1259
+ const dir = guardDir(home);
1260
+ if (!existsSync(dir)) return [];
1261
+ const out = [];
1262
+ const resolvedHome = resolve(home);
1263
+ for (const entry of readdirSync(dir)) {
1264
+ if (!/^pending-.+\.json$/.test(entry)) continue;
1265
+ const filePath = join(dir, entry);
1266
+ try {
1267
+ const pending = sanitizeSnapshot(readJson(filePath), resolvedHome);
1268
+ assertStoredTransactionMatches(pending);
1269
+ out.push(pending);
1270
+ } catch (error) {
1271
+ // Report the corrupt marker instead of silently dropping it, but leave
1272
+ // the file on disk for manual inspection.
1273
+ out.push({ error: error.message, markerPath: filePath });
1274
+ }
1275
+ }
1276
+ return out;
1277
+ }
1278
+
1279
+ /** Verify that every profile-owned reference to a removed package is gone. */
1280
+ export function validateRemoveCompletion(profileDir, candidateName) {
1281
+ const issues = [];
1282
+ if (typeof candidateName !== "string" || !NPM_PACKAGE_NAME_RE.test(candidateName)) {
1283
+ return { ok: false, issues: [issue("block", "remove-incomplete", "Plugin removal is incomplete", "The pending remove does not identify a valid package.")] };
1284
+ }
1285
+ let manifest;
1286
+ try {
1287
+ manifest = readJson(join(profileDir, "package.json"));
1288
+ } catch (error) {
1289
+ return { ok: false, issues: [issue("block", "remove-incomplete", "Plugin removal is incomplete", `The profile manifest cannot be read after remove: ${error.message}`)] };
1290
+ }
1291
+ const dependencies = manifest?.dependencies ?? {};
1292
+ if (dependencies === null || typeof dependencies !== "object" || Array.isArray(dependencies)) {
1293
+ issues.push(issue("block", "remove-incomplete", "Plugin removal is incomplete", "package.json dependencies is not an object."));
1294
+ } else if (Object.prototype.hasOwnProperty.call(dependencies, candidateName)) {
1295
+ issues.push(issue("block", "remove-incomplete", "Plugin removal is incomplete", `${candidateName} is still listed in package.json dependencies.`));
1296
+ }
1297
+ const bundles = manifest?.dsh?.profile?.bundles ?? [];
1298
+ if (!Array.isArray(bundles)) {
1299
+ issues.push(issue("block", "remove-incomplete", "Plugin removal is incomplete", "package.json dsh.profile.bundles is not an array."));
1300
+ } else if (bundles.includes(candidateName)) {
1301
+ issues.push(issue("block", "remove-incomplete", "Plugin removal is incomplete", `${candidateName} is still listed in dsh.profile.bundles.`));
1302
+ }
1303
+
1304
+ const profilePatch = join(profileDir, "cordis.patch.yml");
1305
+ if (!existsSync(profilePatch)) return { ok: issues.length === 0, issues };
1306
+ const patchIssues = [];
1307
+ const rows = parsePatch(profilePatch, "profile", patchIssues, "profile cordis.patch.yml");
1308
+ issues.push(...patchIssues.filter((entry) => entry.severity === "block"));
1309
+ if (rows.some((row) => row.name === candidateName)) {
1310
+ issues.push(issue("block", "remove-incomplete", "Plugin removal is incomplete", `${candidateName} is still mounted by a profile cordis.patch.yml row.`));
1311
+ }
1312
+ return { ok: issues.length === 0, issues };
1313
+ }
1314
+
1315
+ /**
1316
+ * Consume one profile's pending marker: validate, then commit or roll back.
1317
+ * @param profileDir - the profile directory (may come straight from the marker).
1318
+ * @returns {{action: "none"|"committed"|"rolled-back", issues?, removed?}}
1319
+ */
1320
+ export function recoverProfile(profileDir) {
1321
+ const pending = readValidatedPendingSnapshot(profileDir);
1322
+ if (pending === undefined) return { action: "none" };
1323
+ const validation = validateInstalledProfile(profileDir);
1324
+ const isRemove = pending.operation === "remove";
1325
+ const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
1326
+ const removeValidation = isRemove
1327
+ ? validateRemoveCompletion(pending.profileDir, candidateName)
1328
+ : { ok: true, issues: [] };
1329
+ if (validation.ok && removeValidation.ok) {
1330
+ commitPendingSnapshot(profileDir);
1331
+ return { action: "committed", issues: validation.issues.filter((entry) => entry.severity === "warn") };
1332
+ }
1333
+ const recoveryIssues = [...validation.issues, ...removeValidation.issues];
1334
+ const added = isRemove ? [] : addedDependencyNames(pending);
1335
+ rollbackPendingSnapshot(profileDir);
1336
+ return { action: "rolled-back", issues: recoveryIssues, removed: added };
1337
+ }
1338
+
1339
+ /** Recover every profile with a pending marker under a dsh home. */
1340
+ export function recoverAll(home = resolveDshHome()) {
1341
+ const results = [];
1342
+ for (const pending of listPendingSnapshots(home)) {
1343
+ if (pending.error !== undefined) {
1344
+ results.push({ action: "error", error: pending.error, markerPath: pending.markerPath });
1345
+ continue;
1346
+ }
1347
+ try {
1348
+ results.push({ profileDir: pending.profileDir, ...recoverProfile(pending.profileDir) });
1349
+ } catch (error) {
1350
+ results.push({ profileDir: pending.profileDir, action: "error", error: error.message });
1351
+ }
1352
+ }
1353
+ return results;
1354
+ }
1355
+
1356
+ /** Offline fixture entry used after installation (`node src/guard.js --self-test`). */
1357
+ async function selfTest() {
1358
+ const root = mkdtempSync(join(tmpdir(), "dsh-guard-fixture-"));
1359
+ try {
1360
+ // Nest the profile like resolveProfileDir does (<home>/profiles/<name>) so
1361
+ // guardHome (<home>/guard) stays inside the fixture instead of spilling a
1362
+ // `guard` directory next to the OS temp folder.
1363
+ const profileDir = join(root, "profiles", "web");
1364
+ mkdirSync(join(profileDir, "node_modules", "old-sidebar"), { recursive: true });
1365
+ writeFileSync(join(profileDir, "package.json"), JSON.stringify({
1366
+ dependencies: { "old-sidebar": "1.0.0" },
1367
+ dsh: { profile: { bundles: ["old-sidebar"] } },
1368
+ }));
1369
+ writeFileSync(join(profileDir, "cordis.patch.yml"), "[]\n");
1370
+ writeFileSync(join(profileDir, "node_modules", "old-sidebar", "package.json"), JSON.stringify({
1371
+ name: "old-sidebar", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
1372
+ }));
1373
+ writeFileSync(join(profileDir, "node_modules", "old-sidebar", "cordis.patch.yml"), "- insert:\n - id: sidebar\n name: old-sidebar\n");
1374
+ const candidateDir = join(root, "candidate");
1375
+ mkdirSync(candidateDir);
1376
+ writeFileSync(join(candidateDir, "package.json"), JSON.stringify({
1377
+ name: "new-sidebar", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
1378
+ }));
1379
+ writeFileSync(join(candidateDir, "cordis.patch.yml"), "- insert:\n - id: sidebar\n name: new-sidebar\n");
1380
+ const report = inspectCandidate({ profileDir, candidateManifestPath: join(candidateDir, "package.json"), spec: "new-sidebar" });
1381
+ if (report.verdict !== "blocked" || !report.issues.some((entry) => entry.code === "loader-id-collision")) throw new Error("loader id collision fixture failed");
1382
+
1383
+ // Browsing-time remote scan (inspectRemoteCandidate): the candidate is a
1384
+ // manifest + patch text straight from the repo, nothing materialized on
1385
+ // disk. profileDir still mounts old-sidebar with loader id "sidebar".
1386
+ {
1387
+ const collision = inspectRemoteCandidate({
1388
+ profileDir,
1389
+ manifest: { name: "remote-sidebar", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
1390
+ patchText: "- insert:\n - id: sidebar\n name: remote-sidebar\n",
1391
+ spec: "github:owner/remote-sidebar",
1392
+ });
1393
+ if (collision.verdict !== "blocked" || !collision.issues.some((entry) => entry.code === "loader-id-collision")) throw new Error("remote loader-id collision fixture failed");
1394
+
1395
+ const declared = inspectRemoteCandidate({
1396
+ profileDir,
1397
+ manifest: { name: "remote-conflict", version: "1.0.0", dsh: { client: {}, conflicts: ["old-sidebar"] } },
1398
+ spec: "github:owner/remote-conflict",
1399
+ });
1400
+ if (declared.verdict !== "blocked" || !declared.issues.some((entry) => entry.code === "declared-conflict")) throw new Error("remote declared-conflict fixture failed");
1401
+
1402
+ // A bundle whose patch could not be fetched warns instead of fabricating
1403
+ // loader rows — the badge must say "unverified", never "compatible".
1404
+ const unverified = inspectRemoteCandidate({
1405
+ profileDir,
1406
+ manifest: { name: "remote-nopatch", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
1407
+ patchText: undefined,
1408
+ spec: "github:owner/remote-nopatch",
1409
+ });
1410
+ if (unverified.verdict !== "warning" || !unverified.issues.some((entry) => entry.code === "patch-unverified")) throw new Error("remote patch-unverified fixture failed");
1411
+ if (unverified.candidate.rows.length !== 0) throw new Error("patch-unverified scan must not fabricate loader rows");
1412
+
1413
+ const clean = inspectRemoteCandidate({
1414
+ profileDir,
1415
+ manifest: { name: "remote-ui", version: "1.0.0", dsh: { client: {} } },
1416
+ spec: "github:owner/remote-ui",
1417
+ });
1418
+ if (clean.verdict !== "safe") throw new Error(`remote clean client fixture failed: ${clean.summary}`);
1419
+
1420
+ const malformed = inspectRemoteCandidate({
1421
+ profileDir,
1422
+ manifest: { name: "remote-bad", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
1423
+ patchText: "not: [valid",
1424
+ spec: "github:owner/remote-bad",
1425
+ });
1426
+ if (malformed.verdict !== "blocked" || !malformed.issues.some((entry) => entry.code === "patch-invalid")) throw new Error("remote malformed patch fixture failed");
1427
+ }
1428
+
1429
+ // Peer version checks resolve host packages through Node's upward lookup:
1430
+ // the host lives in the shared profiles/node_modules (the profile's own
1431
+ // node_modules has no @deepseek-ai/*), and missing that used to emit a
1432
+ // bogus "peer-unresolved" warning for every well-declared plugin.
1433
+ {
1434
+ const p = join(root, "profiles", "peerup");
1435
+ mkdirSync(join(p, "node_modules"), { recursive: true });
1436
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
1437
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1438
+ const hostDir = join(root, "profiles", "node_modules", "@deepseek-ai", "fake-host-fixture");
1439
+ mkdirSync(hostDir, { recursive: true });
1440
+ writeFileSync(join(hostDir, "package.json"), JSON.stringify({ name: "@deepseek-ai/fake-host-fixture", version: "2.0.0" }));
1441
+
1442
+ const incompatible = inspectRemoteCandidate({
1443
+ profileDir: p,
1444
+ manifest: { name: "peer-cand", version: "1.0.0", dsh: { client: {} }, peerDependencies: { "@deepseek-ai/fake-host-fixture": "^1.0.0" } },
1445
+ spec: "peer-cand",
1446
+ });
1447
+ if (incompatible.issues.some((entry) => entry.code === "peer-unresolved")) throw new Error("host in shared profiles/node_modules must resolve via upward lookup");
1448
+ if (!incompatible.issues.some((entry) => entry.code === "peer-version")) throw new Error("incompatible host peer must block once resolved");
1449
+
1450
+ const compatible = inspectRemoteCandidate({
1451
+ profileDir: p,
1452
+ manifest: { name: "peer-cand", version: "1.0.0", dsh: { client: {} }, peerDependencies: { "@deepseek-ai/fake-host-fixture": "^2.0.0" } },
1453
+ spec: "peer-cand",
1454
+ });
1455
+ if (compatible.issues.some((entry) => entry.code === "peer-unresolved" || entry.code === "peer-version")) throw new Error("compatible host peer must produce no peer issues");
1456
+ }
1457
+
1458
+ // Snapshot → restore round-trip.
1459
+ const snapshot = createProfileSnapshot(profileDir, { fixture: true });
1460
+ writeFileSync(join(profileDir, "package.json"), "{}\n");
1461
+ restoreProfileSnapshot({ ...snapshot, preflight: { candidate: { name: "fresh-plugin" } } });
1462
+ if (!readFileSync(join(profileDir, "package.json"), "utf8").includes("old-sidebar")) throw new Error("snapshot restore fixture failed");
1463
+
1464
+ // Pending marker → rollback round-trip (the guarded-install rollback path).
1465
+ // The candidate is a NEW dependency, so no reconcile is needed.
1466
+ markPendingSnapshot(snapshot, { fixture: true, preflight: { candidate: { name: "fresh-plugin", version: "1.0.0" } } });
1467
+ writeFileSync(join(profileDir, "package.json"), "{}\n");
1468
+ if (readPendingSnapshot(profileDir)?.id !== snapshot.id) throw new Error("pending marker fixture failed");
1469
+ rollbackPendingSnapshot(profileDir);
1470
+ if (!readFileSync(join(profileDir, "package.json"), "utf8").includes("old-sidebar")) throw new Error("pending rollback fixture failed");
1471
+ if (readPendingSnapshot(profileDir) !== undefined) throw new Error("pending rollback should clear the marker");
1472
+ if (existsSync(snapshot.dir)) throw new Error("pending rollback should delete the snapshot dir");
1473
+
1474
+ // Pending marker → commit round-trip (accept the installed state).
1475
+ const snapshot2 = createProfileSnapshot(profileDir, { fixture: true });
1476
+ markPendingSnapshot(snapshot2, { fixture: true, preflight: { candidate: { name: "fresh-plugin", version: "1.0.0" } } });
1477
+ writeFileSync(join(profileDir, "package.json"), "{}\n");
1478
+ commitPendingSnapshot(profileDir);
1479
+ if (readPendingSnapshot(profileDir) !== undefined) throw new Error("pending commit should clear the marker");
1480
+ if (existsSync(snapshot2.dir)) throw new Error("pending commit should delete the snapshot dir");
1481
+ if (readFileSync(join(profileDir, "package.json"), "utf8") !== "{}\n") throw new Error("pending commit must not restore the profile");
1482
+
1483
+ // Scoped dsh.conflicts: "@scope/name@1.0.0" must strip to "@scope/name",
1484
+ // not "" (a naive split("@")[0]).
1485
+ {
1486
+ const p = join(root, "profiles", "scoped");
1487
+ mkdirSync(join(p, "node_modules", "@scope", "other"), { recursive: true });
1488
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "@scope/other": "1.0.0" } }));
1489
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1490
+ writeFileSync(join(p, "node_modules", "@scope", "other", "package.json"), JSON.stringify({ name: "@scope/other", version: "1.0.0" }));
1491
+ const cand = join(root, "scoped-candidate");
1492
+ mkdirSync(cand);
1493
+ writeFileSync(join(cand, "package.json"), JSON.stringify({ name: "scoped-candidate", version: "1.0.0", dsh: { conflicts: ["@scope/other@1.0.0"] } }));
1494
+ const rep = inspectCandidate({ profileDir: p, candidateManifestPath: join(cand, "package.json"), spec: "scoped-candidate" });
1495
+ const conflict = rep.issues.find((entry) => entry.code === "declared-conflict");
1496
+ if (rep.verdict !== "blocked" || conflict === undefined || conflict.conflictsWith?.[0] !== "@scope/other") throw new Error("scoped conflict fixture failed");
1497
+ if (validateInstalledProfile(p).ok !== true) throw new Error("healthy profile should validate clean");
1498
+ }
1499
+
1500
+ // validateInstalledProfile detects a loader-id collision between two
1501
+ // already-installed bundles (the failure mode a bad install introduces).
1502
+ {
1503
+ const p = join(root, "profiles", "broken");
1504
+ mkdirSync(join(p, "node_modules", "a"), { recursive: true });
1505
+ mkdirSync(join(p, "node_modules", "b"), { recursive: true });
1506
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { a: "1.0.0", b: "1.0.0" }, dsh: { profile: { bundles: ["a", "b"] } } }));
1507
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1508
+ writeFileSync(join(p, "node_modules", "a", "package.json"), JSON.stringify({ name: "a", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
1509
+ writeFileSync(join(p, "node_modules", "a", "cordis.patch.yml"), "- insert:\n - id: dup\n name: a\n");
1510
+ writeFileSync(join(p, "node_modules", "b", "package.json"), JSON.stringify({ name: "b", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
1511
+ writeFileSync(join(p, "node_modules", "b", "cordis.patch.yml"), "- insert:\n - id: dup\n name: b\n");
1512
+ const v = validateInstalledProfile(p);
1513
+ if (v.ok !== false || v.verdict !== "blocked" || !v.issues.some((entry) => entry.code === "loader-id-collision")) throw new Error("validateInstalledProfile collision fixture failed");
1514
+ }
1515
+
1516
+ // Official patches tag raw JS with `!!js` (e.g. `value: !!js process.env.X`).
1517
+ // It must parse as an inert string — never executed — with no patch-parse
1518
+ // warning, the insert rows intact, and any other unknown tag still rejected.
1519
+ {
1520
+ const p = join(root, "profiles", "js-tag");
1521
+ mkdirSync(join(p, "node_modules"), { recursive: true });
1522
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
1523
+ writeFileSync(join(p, "cordis.patch.yml"), "- insert:\n - id: js-scalar\n name: js-scalar\n value: !!js globalThis.__dshGuardJsTagExecuted = true\n");
1524
+ const issues = [];
1525
+ const rows = parsePatch(join(p, "cordis.patch.yml"), "profile", issues, "js-tag fixture");
1526
+ if (issues.length !== 0) throw new Error("!!js scalar fixture should parse without patch warnings");
1527
+ if (rows.length !== 1 || rows[0].id !== "js-scalar" || rows[0].name !== "js-scalar") throw new Error("!!js scalar fixture should still yield insert rows");
1528
+ if (globalThis.__dshGuardJsTagExecuted !== undefined) throw new Error("!!js scalar must never be executed");
1529
+ const v = validateInstalledProfile(p);
1530
+ if (v.ok !== true) throw new Error("profile with a !!js scalar patch should validate clean");
1531
+ writeFileSync(join(p, "cordis.patch.yml"), "- insert:\n - id: evil\n name: evil\n value: !unknown-tag still-rejected\n");
1532
+ const rejected = [];
1533
+ parsePatch(join(p, "cordis.patch.yml"), "profile", rejected, "js-tag fixture");
1534
+ if (!rejected.some((entry) => entry.code === "patch-invalid")) throw new Error("other unknown tags must stay rejected");
1535
+ }
1536
+
1537
+ // recoverProfile: a pending install that collides is rolled back, its
1538
+ // node_modules entry removed, and the marker consumed.
1539
+ {
1540
+ const p = join(root, "profiles", "recover");
1541
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1542
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" }, dsh: { profile: { bundles: ["good"] } } }));
1543
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1544
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
1545
+ writeFileSync(join(p, "node_modules", "good", "cordis.patch.yml"), "- insert:\n - id: good\n name: good\n");
1546
+ const snap = createProfileSnapshot(p, { fixture: true });
1547
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0", bad: "1.0.0" }, dsh: { profile: { bundles: ["good", "bad"] } } }));
1548
+ mkdirSync(join(p, "node_modules", "bad"), { recursive: true });
1549
+ writeFileSync(join(p, "node_modules", "bad", "package.json"), JSON.stringify({ name: "bad", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
1550
+ writeFileSync(join(p, "node_modules", "bad", "cordis.patch.yml"), "- insert:\n - id: good\n name: bad\n");
1551
+ markPendingSnapshot(snap, { spec: "bad", preflight: { candidate: { name: "bad", version: "1.0.0", kind: "bundle", rows: [{ id: "good", name: "bad" }] }, verdict: "safe", issues: [] } });
1552
+ const rec = recoverProfile(p);
1553
+ if (rec.action !== "rolled-back") throw new Error(`recoverProfile should roll back a colliding install, got ${rec.action}`);
1554
+ const restored = readJson(join(p, "package.json"));
1555
+ if (restored.dependencies?.bad !== undefined) throw new Error("recoverProfile should remove the bad dependency entry");
1556
+ if (existsSync(join(p, "node_modules", "bad"))) throw new Error("recoverProfile should reconcile node_modules (remove 'bad')");
1557
+ if (readPendingSnapshot(p) !== undefined) throw new Error("recoverProfile should clear the pending marker");
1558
+ }
1559
+
1560
+ // recoverProfile: a healthy pending install is committed, not restored.
1561
+ {
1562
+ const p = join(root, "profiles", "commit-ok");
1563
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1564
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" }, dsh: { profile: { bundles: ["good"] } } }));
1565
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1566
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
1567
+ writeFileSync(join(p, "node_modules", "good", "cordis.patch.yml"), "- insert:\n - id: good\n name: good\n");
1568
+ const snap = createProfileSnapshot(p, { fixture: true });
1569
+ markPendingSnapshot(snap, { spec: "good", preflight: { candidate: { name: "good", version: "1.0.0", kind: "bundle" } } });
1570
+ const rec = recoverProfile(p);
1571
+ if (rec.action !== "committed") throw new Error(`recoverProfile should commit a healthy install, got ${rec.action}`);
1572
+ if (readPendingSnapshot(p) !== undefined) throw new Error("recoverProfile commit should clear the marker");
1573
+ if (existsSync(snap.dir)) throw new Error("recoverProfile commit should delete the snapshot dir");
1574
+ }
1575
+
1576
+ // Remove rollback, no-op failure: the official command failed before
1577
+ // touching the package. Rollback must preserve the healthy direct package
1578
+ // and must not run the install/update path's candidate pruning logic.
1579
+ {
1580
+ const p = join(root, "profiles", "remove-noop");
1581
+ mkdirSync(join(p, "node_modules", "victim"), { recursive: true });
1582
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { victim: "1.0.0" } }));
1583
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1584
+ writeFileSync(join(p, "node_modules", "victim", "package.json"), JSON.stringify({ name: "victim", version: "1.0.0" }));
1585
+ const snap = createProfileSnapshot(p, { operation: "remove", packageName: "victim" });
1586
+ markPendingSnapshot(snap, { operation: "remove", candidate: { name: "victim" } });
1587
+ rollbackPendingSnapshot(p);
1588
+ if (!candidateRestoredCompatible(p, "victim", "1.0.0")) {
1589
+ throw new Error("remove no-op rollback must preserve the original healthy package");
1590
+ }
1591
+ if (readPendingSnapshot(p) !== undefined || existsSync(snap.dir)) {
1592
+ throw new Error("successful remove no-op rollback must consume its recovery state");
1593
+ }
1594
+ }
1595
+
1596
+ // Pending transaction tampering must be rejected before restore, package
1597
+ // pruning, reconciliation, commit, or marker cleanup. In particular, a
1598
+ // remove marker cannot be relabelled as install to enter add rollback.
1599
+ {
1600
+ const variants = [
1601
+ ["unsupported operation", (marker) => { marker.operation = "other"; }],
1602
+ ["top-level/metadata operation mismatch", (marker) => { marker.operation = "install"; }],
1603
+ ["remove candidate/packageName mismatch", (marker) => { marker.candidate.name = "attacker-choice"; }],
1604
+ ["marker and metadata relabelled together", (marker) => {
1605
+ marker.operation = "install";
1606
+ marker.metadata.operation = "install";
1607
+ delete marker.metadata.packageName;
1608
+ }],
1609
+ ];
1610
+ for (let index = 0; index < variants.length; index++) {
1611
+ const [label, tamper] = variants[index];
1612
+ const p = join(root, "profiles", `remove-tamper-${index}`);
1613
+ const victimDir = join(p, "node_modules", "victim");
1614
+ mkdirSync(victimDir, { recursive: true });
1615
+ const manifestBytes = JSON.stringify({ dependencies: { victim: "1.0.0" } });
1616
+ writeFileSync(join(p, "package.json"), manifestBytes);
1617
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1618
+ writeFileSync(join(victimDir, "package.json"), JSON.stringify({ name: "victim", version: "1.0.0" }));
1619
+ const snap = createProfileSnapshot(p, { operation: "remove", packageName: "victim" });
1620
+ markPendingSnapshot(snap, { operation: "remove", candidate: { name: "victim" } });
1621
+ const markerPath = pendingPath(p);
1622
+ const marker = readJson(markerPath);
1623
+ tamper(marker);
1624
+ const tamperedBytes = JSON.stringify(marker, undefined, 2) + "\n";
1625
+ writeFileSync(markerPath, tamperedBytes);
1626
+
1627
+ for (const fn of [commitPendingSnapshot, rollbackPendingSnapshot, recoverProfile]) {
1628
+ let threw = false;
1629
+ try { fn(p); } catch { threw = true; }
1630
+ if (!threw) throw new Error(`${fn.name} must reject remove marker tampering: ${label}`);
1631
+ if (readFileSync(markerPath, "utf8") !== tamperedBytes || !existsSync(snap.dir)) {
1632
+ throw new Error(`${fn.name} must retain marker/snapshot evidence after tampering: ${label}`);
1633
+ }
1634
+ if (readFileSync(join(p, "package.json"), "utf8") !== manifestBytes || !existsSync(join(victimDir, "package.json"))) {
1635
+ throw new Error(`${fn.name} must not mutate profile/node_modules after tampering: ${label}`);
1636
+ }
1637
+ }
1638
+ }
1639
+ }
1640
+
1641
+ // Interrupted remove recovery: pnpm deleted dependencies/node_modules but
1642
+ // dsh crashed before removing the bundle/profile row. Generic validation
1643
+ // considers this loadable, so the remove-specific completion check must
1644
+ // force rollback. A temp PATH stub models the real offline lockfile
1645
+ // reconcile and restores the deleted direct package.
1646
+ {
1647
+ const p = join(root, "profiles", "remove-partial");
1648
+ mkdirSync(join(p, "node_modules", "victim"), { recursive: true });
1649
+ writeFileSync(join(p, "package.json"), JSON.stringify({
1650
+ dependencies: { victim: "1.0.0" },
1651
+ dsh: { profile: { bundles: ["victim"] } },
1652
+ }));
1653
+ writeFileSync(join(p, "cordis.patch.yml"), "- insert:\n - id: victim-row\n name: victim\n");
1654
+ writeFileSync(join(p, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n\nimporters: {}\n");
1655
+ writeFileSync(join(p, "node_modules", "victim", "package.json"), JSON.stringify({ name: "victim", version: "1.0.0" }));
1656
+ const snap = createProfileSnapshot(p, { operation: "remove", packageName: "victim" });
1657
+ markPendingSnapshot(snap, { operation: "remove", candidate: { name: "victim" } });
1658
+
1659
+ writeFileSync(join(p, "package.json"), JSON.stringify({
1660
+ dependencies: {},
1661
+ dsh: { profile: { bundles: ["victim"] } },
1662
+ }));
1663
+ rmSync(join(p, "node_modules", "victim"), { recursive: true, force: true });
1664
+ if (!validateInstalledProfile(p).ok) throw new Error("partial remove fixture must reproduce the generic-validation false safe");
1665
+
1666
+ const binDir = join(root, "remove-partial-bin");
1667
+ mkdirSync(binDir);
1668
+ const isWin = process.platform === "win32";
1669
+ const stubPath = join(binDir, isWin ? "pnpm.cmd" : "pnpm");
1670
+ writeFileSync(stubPath, isWin
1671
+ ? "@echo off\r\nmkdir node_modules\\victim 2>nul\r\necho {\"name\":\"victim\",\"version\":\"1.0.0\"}> node_modules\\victim\\package.json\r\nexit /b 0\r\n"
1672
+ : "#!/bin/sh\nmkdir -p node_modules/victim\nprintf '%s' '{\"name\":\"victim\",\"version\":\"1.0.0\"}' > node_modules/victim/package.json\nexit 0\n");
1673
+ if (!isWin) chmodSync(stubPath, 0o755);
1674
+ const previousPath = process.env.PATH;
1675
+ process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
1676
+ let recovered;
1677
+ try {
1678
+ recovered = recoverProfile(p);
1679
+ } finally {
1680
+ if (previousPath === undefined) delete process.env.PATH;
1681
+ else process.env.PATH = previousPath;
1682
+ }
1683
+ if (recovered.action !== "rolled-back" || !recovered.issues.some((entry) => entry.code === "remove-incomplete")) {
1684
+ throw new Error("partial remove recovery must roll back instead of committing a false-safe state");
1685
+ }
1686
+ const restored = readJson(join(p, "package.json"));
1687
+ if (restored.dependencies?.victim !== "1.0.0" || !restored.dsh?.profile?.bundles?.includes("victim")) {
1688
+ throw new Error("partial remove recovery must restore manifest dependency and bundle references");
1689
+ }
1690
+ if (!readFileSync(join(p, "cordis.patch.yml"), "utf8").includes("name: victim")) {
1691
+ throw new Error("partial remove recovery must restore the profile row");
1692
+ }
1693
+ if (!candidateRestoredCompatible(p, "victim", "1.0.0")) {
1694
+ throw new Error("partial remove recovery must restore the deleted direct package offline");
1695
+ }
1696
+ if (readPendingSnapshot(p) !== undefined || existsSync(snap.dir)) {
1697
+ throw new Error("successful partial remove rollback must consume recovery state");
1698
+ }
1699
+ }
1700
+
1701
+ // reconcileNodeModules prunes a leftover scoped entry and its empty scope.
1702
+ {
1703
+ const p = join(root, "profiles", "rm-nm");
1704
+ mkdirSync(join(p, "node_modules", "@x", "y"), { recursive: true });
1705
+ writeFileSync(join(p, "node_modules", "@x", "y", "package.json"), JSON.stringify({ name: "@x/y", version: "1.0.0" }));
1706
+ const removed = reconcileNodeModules(p, ["@x/y", "not-a-name!"]);
1707
+ if (removed.length !== 1 || removed[0] !== "@x/y") throw new Error("reconcileNodeModules scoped fixture failed");
1708
+ if (existsSync(join(p, "node_modules", "@x", "y"))) throw new Error("reconcileNodeModules should remove the package dir");
1709
+ if (existsSync(join(p, "node_modules", "@x"))) throw new Error("reconcileNodeModules should prune the emptied scope dir");
1710
+ }
1711
+
1712
+ // listPendingSnapshots / recoverAll iterate every profile under a home.
1713
+ {
1714
+ const p = join(root, "profiles", "all-1");
1715
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1716
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" }, dsh: { profile: { bundles: ["good"] } } }));
1717
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1718
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
1719
+ writeFileSync(join(p, "node_modules", "good", "cordis.patch.yml"), "- insert:\n - id: good\n name: good\n");
1720
+ const snap = createProfileSnapshot(p, { fixture: true });
1721
+ markPendingSnapshot(snap, { spec: "good", preflight: { candidate: { name: "good", version: "1.0.0" } } });
1722
+ const listed = listPendingSnapshots(root);
1723
+ if (!listed.some((entry) => entry.profileDir === p)) throw new Error("listPendingSnapshots should include the new pending marker");
1724
+ const all = recoverAll(root);
1725
+ if (!all.some((entry) => entry.profileDir === p && entry.action === "committed")) throw new Error("recoverAll should commit the healthy pending profile");
1726
+ if (readPendingSnapshot(p) !== undefined) throw new Error("recoverAll should clear the marker");
1727
+ }
1728
+
1729
+ // Malicious pending markers: marker.dir / profileDir are attacker-written
1730
+ // and must never drive a filesystem operation. commit/rollback/recover must
1731
+ // refuse any marker that escapes <home>/profiles, and the outside sentinel
1732
+ // directory must remain untouched.
1733
+ {
1734
+ const sentinel = join(root, "sentinel-outside");
1735
+ mkdirSync(sentinel);
1736
+ writeFileSync(join(sentinel, "keep.txt"), "keep");
1737
+ const evil = join(root, "profiles", "evil");
1738
+ mkdirSync(join(evil, "node_modules", "good"), { recursive: true });
1739
+ writeFileSync(join(evil, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
1740
+ writeFileSync(join(evil, "cordis.patch.yml"), "[]\n");
1741
+ writeFileSync(join(evil, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
1742
+ const marker = {
1743
+ version: SNAPSHOT_VERSION,
1744
+ id: "1234-abcd",
1745
+ dir: sentinel,
1746
+ profileDir: sentinel,
1747
+ files: Object.fromEntries(PROFILE_FILES.map((name) => [name, { present: false }])),
1748
+ dependencies: [],
1749
+ preflight: { candidate: { name: "good", version: "1.0.0" } },
1750
+ pendingAt: Date.now(),
1751
+ };
1752
+ mkdirSync(guardHome(evil), { recursive: true });
1753
+ writeFileSync(pendingPath(evil), JSON.stringify(marker, undefined, 2) + "\n");
1754
+ for (const fn of [commitPendingSnapshot, rollbackPendingSnapshot, recoverProfile]) {
1755
+ let threw = false;
1756
+ try { fn(evil); } catch { threw = true; }
1757
+ if (!threw) throw new Error(`${fn.name} should refuse a marker whose profileDir escapes <home>/profiles`);
1758
+ if (!existsSync(join(sentinel, "keep.txt"))) throw new Error(`${fn.name} must not delete the outside sentinel`);
1759
+ }
1760
+ if (!existsSync(pendingPath(evil))) throw new Error("refused marker must remain on disk for manual inspection");
1761
+ const listed = listPendingSnapshots(root);
1762
+ if (!listed.some((entry) => entry.error !== undefined)) throw new Error("listPendingSnapshots should report the corrupt marker");
1763
+ const recovered = recoverAll(root);
1764
+ if (!recovered.some((entry) => entry.action === "error")) throw new Error("recoverAll should report the corrupt marker");
1765
+ }
1766
+
1767
+ // A marker with a valid profileDir but a `dir` pointing at the sentinel:
1768
+ // `dir` is recomputed from the validated profileDir + id, so the sentinel is
1769
+ // never deleted — the real snapshot dir is the one commit removes.
1770
+ {
1771
+ const p = join(root, "profiles", "dir-spoof");
1772
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1773
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
1774
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1775
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
1776
+ const snap = createProfileSnapshot(p, { fixture: true });
1777
+ markPendingSnapshot(snap, { fixture: true, preflight: { candidate: { name: "good" } } });
1778
+ const markerPath = pendingPath(p);
1779
+ const tampered = readJson(markerPath);
1780
+ tampered.dir = join(root, "sentinel-outside");
1781
+ writeFileSync(markerPath, JSON.stringify(tampered, undefined, 2) + "\n");
1782
+ commitPendingSnapshot(p);
1783
+ if (!existsSync(join(root, "sentinel-outside", "keep.txt"))) throw new Error("marker.dir must never be used as a delete target");
1784
+ if (existsSync(snap.dir)) throw new Error("commit should delete the recomputed snapshot dir, not marker.dir");
1785
+ }
1786
+
1787
+ // markPendingSnapshot never supersedes an existing pending marker: the old
1788
+ // marker and its snapshot must survive a refused re-mark.
1789
+ {
1790
+ const p = join(root, "profiles", "no-supersede");
1791
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1792
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
1793
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1794
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
1795
+ const first = createProfileSnapshot(p, { fixture: true });
1796
+ markPendingSnapshot(first, { spec: "good", preflight: { candidate: { name: "fresh-plugin" } } });
1797
+ const second = createProfileSnapshot(p, { fixture: true });
1798
+ let threw = false;
1799
+ try { markPendingSnapshot(second, { spec: "good", preflight: { candidate: { name: "fresh-plugin" } } }); } catch { threw = true; }
1800
+ if (!threw) throw new Error("markPendingSnapshot should refuse to supersede an existing pending marker");
1801
+ if (readPendingSnapshot(p)?.id !== first.id) throw new Error("the original pending marker must survive a refused re-mark");
1802
+ if (!existsSync(first.dir)) throw new Error("the original snapshot dir must survive a refused re-mark");
1803
+ rollbackPendingSnapshot(p);
1804
+ }
1805
+
1806
+ // Fail-closed pending schema (v2): a marker without trustworthy dependency
1807
+ // metadata or candidate identity — including a legacy v1 marker — must
1808
+ // cause NO mutation and NO clearing. rollback, commit, and recover all
1809
+ // refuse; the marker, the snapshot, the profile files, and node_modules
1810
+ // all stay exactly as they were (recovery evidence is retained).
1811
+ {
1812
+ const p = join(root, "profiles", "corrupt-marker");
1813
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1814
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
1815
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1816
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
1817
+ const manifestBefore = readFileSync(join(p, "package.json"), "utf8");
1818
+ const tamper = (label, mutate) => {
1819
+ const snap = createProfileSnapshot(p, { fixture: true });
1820
+ markPendingSnapshot(snap, { spec: "good", preflight: { candidate: { name: "good", version: "1.0.0" } } });
1821
+ const markerPath = pendingPath(p);
1822
+ const marker = readJson(markerPath);
1823
+ mutate(marker);
1824
+ writeFileSync(markerPath, JSON.stringify(marker, undefined, 2) + "\n");
1825
+ for (const fn of [rollbackPendingSnapshot, commitPendingSnapshot, recoverProfile]) {
1826
+ let threw = false;
1827
+ try { fn(p); } catch { threw = true; }
1828
+ if (!threw) throw new Error(`${fn.name} must refuse a marker with ${label}`);
1829
+ }
1830
+ if (!existsSync(markerPath)) throw new Error(`a marker with ${label} must stay on disk`);
1831
+ if (!existsSync(snap.dir)) throw new Error(`the snapshot dir of a marker with ${label} must stay on disk`);
1832
+ if (readFileSync(join(p, "package.json"), "utf8") !== manifestBefore) throw new Error(`a marker with ${label} must not mutate the profile manifest`);
1833
+ if (!existsSync(join(p, "node_modules", "good", "package.json"))) throw new Error(`a marker with ${label} must not touch node_modules`);
1834
+ rmSync(markerPath, { force: true });
1835
+ rmSync(snap.dir, { recursive: true, force: true });
1836
+ };
1837
+ tamper("removed dependencies", (marker) => { delete marker.dependencies; });
1838
+ tamper("non-array dependencies", (marker) => { marker.dependencies = "good"; });
1839
+ tamper("corrupt dependency entries", (marker) => { marker.dependencies = ["good", 42]; });
1840
+ tamper("a missing candidate", (marker) => { delete marker.preflight; });
1841
+ tamper("an invalid candidate name", (marker) => { marker.preflight = { candidate: { name: "../../evil" } }; });
1842
+ tamper("a legacy v1 schema version", (marker) => { marker.version = 1; });
1843
+ }
1844
+
1845
+ // validateInstalledProfile blocks a dependency the manifest declares but
1846
+ // node_modules cannot resolve — the crash-mid-install fingerprint that must
1847
+ // never be committed as healthy. A template bundle (bundle-only, never a
1848
+ // dependency) is in-box and stays silent.
1849
+ {
1850
+ const p = join(root, "profiles", "unresolved");
1851
+ mkdirSync(p, { recursive: true });
1852
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { ghost: "1.0.0" } }));
1853
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1854
+ const v = validateInstalledProfile(p);
1855
+ if (v.ok !== false || v.verdict !== "blocked" || !v.issues.some((entry) => entry.code === "package-unresolved")) {
1856
+ throw new Error("validateInstalledProfile should block a declared-but-unresolved dependency");
1857
+ }
1858
+ const q = join(root, "profiles", "template-bundle");
1859
+ mkdirSync(q, { recursive: true });
1860
+ writeFileSync(join(q, "package.json"), JSON.stringify({ dependencies: {}, dsh: { profile: { bundles: ["inbox-ui"] } } }));
1861
+ writeFileSync(join(q, "cordis.patch.yml"), "[]\n");
1862
+ if (validateInstalledProfile(q).ok !== true) throw new Error("a template bundle is in-box and must not be flagged as unresolved");
1863
+ }
1864
+
1865
+ // dsh.bundle.patch paths are clamped to the package directory: an escaping
1866
+ // path is a blocker and is never read.
1867
+ {
1868
+ const p = join(root, "profiles", "escape-patch");
1869
+ mkdirSync(join(p, "node_modules", "evil"), { recursive: true });
1870
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { evil: "1.0.0" }, dsh: { profile: { bundles: ["evil"] } } }));
1871
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1872
+ writeFileSync(join(p, "node_modules", "evil", "package.json"), JSON.stringify({ name: "evil", version: "1.0.0", dsh: { bundle: { patch: "../../outside.patch.yml" } } }));
1873
+ writeFileSync(join(p, "outside.patch.yml"), "- insert:\n - id: x\n name: y\n");
1874
+ const v = validateInstalledProfile(p);
1875
+ if (v.ok !== false || !v.issues.some((entry) => entry.code === "patch-outside-package")) {
1876
+ throw new Error("validateInstalledProfile should block a bundle patch that escapes its package directory");
1877
+ }
1878
+ }
1879
+
1880
+ // preflightInstall rejects a malicious spec at its own boundary, before any
1881
+ // pnpm spawn — this fixture needs no pnpm to prove the spec never reaches it.
1882
+ {
1883
+ const p = join(root, "profiles", "unsafe-spec");
1884
+ mkdirSync(p, { recursive: true });
1885
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
1886
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1887
+ const rep = await preflightInstall({ profileDir: p, spec: "evil-pkg; rm -rf /tmp/x" });
1888
+ if (rep.verdict !== "blocked" || !rep.issues.some((entry) => entry.code === "unsafe-spec")) {
1889
+ throw new Error("preflightInstall should reject a spec with shell metacharacters before spawning");
1890
+ }
1891
+ if (process.platform === "win32") {
1892
+ const repWin = await preflightInstall({ profileDir: p, spec: "file:C:\\some dir\\pkg" });
1893
+ if (repWin.verdict !== "blocked" || !repWin.issues.some((entry) => entry.code === "unsafe-spec")) {
1894
+ throw new Error("preflightInstall should reject a Windows file: spec with spaces before spawning");
1895
+ }
1896
+ }
1897
+ }
1898
+
1899
+ // Probe install args (pure): peer auto-install must stay disabled so the
1900
+ // candidate's host peers (@deepseek-ai/*) are left unsatisfied for
1901
+ // compatibility analysis, and install scripts must stay off.
1902
+ {
1903
+ const args = probeAddArgs("some-plugin@1.0.0");
1904
+ if (args[0] !== "add" || args[1] !== "some-plugin@1.0.0") throw new Error("probe args should be `add <spec>`");
1905
+ if (!args.includes("--config.auto-install-peers=false")) throw new Error("probe args must disable peer auto-install");
1906
+ if (!args.includes("--ignore-scripts")) throw new Error("probe args must keep install scripts disabled");
1907
+ }
1908
+
1909
+ // Rollback reconcile args/env (pure): scripts off, the restored lockfile
1910
+ // authoritative, peer auto-install off, strictly offline — there is no
1911
+ // online retry path or args at all, and nothing user-controlled anywhere
1912
+ // in the argv.
1913
+ {
1914
+ if (reconcileInstallArgs.length !== 0) throw new Error("reconcile args must not take an offline/online switch — there is no online retry");
1915
+ const args = reconcileInstallArgs();
1916
+ for (const expected of ["install", "--ignore-scripts", "--frozen-lockfile", "--config.auto-install-peers=false", "--offline"]) {
1917
+ if (!args.includes(expected)) throw new Error(`reconcile args must include ${expected}`);
1918
+ }
1919
+ const env = pnpmGuardEnv({ KEEP_ME: "1" });
1920
+ if (env.KEEP_ME !== "1") throw new Error("pnpmGuardEnv must preserve the base env");
1921
+ if (env.npm_config_auto_install_peers !== "false" || env.NPM_CONFIG_AUTO_INSTALL_PEERS !== "false") {
1922
+ throw new Error("pnpmGuardEnv must disable peer auto-install");
1923
+ }
1924
+ }
1925
+
1926
+ // candidateRestoredCompatible: version vs the restored dependency spec.
1927
+ {
1928
+ const p = join(root, "profiles", "compat");
1929
+ mkdirSync(join(p, "node_modules", "pkg"), { recursive: true });
1930
+ writeFileSync(join(p, "node_modules", "pkg", "package.json"), JSON.stringify({ name: "pkg", version: "0.1.9" }));
1931
+ if (!candidateRestoredCompatible(p, "pkg", "0.1.9")) throw new Error("exact version should satisfy the exact spec");
1932
+ if (!candidateRestoredCompatible(p, "pkg", "^0.1.0")) throw new Error("a satisfying range should be compatible");
1933
+ if (candidateRestoredCompatible(p, "pkg", "0.1.10")) throw new Error("a mismatched version must be incompatible");
1934
+ if (!candidateRestoredCompatible(p, "pkg", "github:owner/repo")) throw new Error("non-semver specs can only assert presence");
1935
+ if (candidateRestoredCompatible(p, "missing-pkg", "1.0.0")) throw new Error("a missing package must be incompatible");
1936
+ if (candidateRestoredCompatible(p, "../escape", "1.0.0")) throw new Error("invalid names must be incompatible");
1937
+ // Only the DIRECT package counts: a satisfying copy in an ancestor
1938
+ // node_modules (Node resolution would walk up to it) must be refused.
1939
+ mkdirSync(join(root, "node_modules", "outer-pkg"), { recursive: true });
1940
+ writeFileSync(join(root, "node_modules", "outer-pkg", "package.json"), JSON.stringify({ name: "outer-pkg", version: "1.0.0" }));
1941
+ if (candidateRestoredCompatible(p, "outer-pkg", "1.0.0")) throw new Error("an ancestor node_modules copy must never satisfy the check");
1942
+ // The manifest's own name must match the candidate exactly.
1943
+ mkdirSync(join(p, "node_modules", "aliased"), { recursive: true });
1944
+ writeFileSync(join(p, "node_modules", "aliased", "package.json"), JSON.stringify({ name: "not-aliased", version: "0.1.9" }));
1945
+ if (candidateRestoredCompatible(p, "aliased", "0.1.9")) throw new Error("a package whose manifest name differs must be incompatible");
1946
+ }
1947
+
1948
+ // Interrupted UPDATE rollback: the lockfile rebuild cannot complete (the
1949
+ // snapshot lockfile is deliberately stale, so `pnpm install --offline
1950
+ // --frozen-lockfile` fails fast on any pnpm without touching
1951
+ // node_modules) and the old copy of the updated package is still missing.
1952
+ // Rollback must throw and KEEP marker + snapshot for repair.
1953
+ {
1954
+ const p = join(root, "profiles", "update-kept");
1955
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1956
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
1957
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1958
+ writeFileSync(join(p, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n\nimporters: {}\n");
1959
+ // The interrupted update left the NEW version linked in node_modules.
1960
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
1961
+ const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
1962
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
1963
+ let threw = false;
1964
+ try { rollbackPendingSnapshot(p); } catch { threw = true; }
1965
+ if (!threw) throw new Error("an update rollback whose old package cannot be restored should throw");
1966
+ if (readPendingSnapshot(p)?.id !== snap.id) throw new Error("a failed update rollback must KEEP the pending marker");
1967
+ if (!existsSync(snap.dir)) throw new Error("a failed update rollback must KEEP the snapshot dir");
1968
+ if (existsSync(join(p, "node_modules", "good"))) throw new Error("the interrupted new version must still be removed");
1969
+ if (readJson(join(p, "package.json")).dependencies?.good !== "1.0.0") throw new Error("the manifest must stay restored even when the rebuild fails");
1970
+ rmSync(pendingPath(p), { force: true });
1971
+ rmSync(snap.dir, { recursive: true, force: true });
1972
+ }
1973
+
1974
+ // Interrupted UPDATE rollback where the single offline reconcile exits
1975
+ // NONZERO yet the old copy of the candidate is present again at the
1976
+ // profile's OWN node_modules — the live "unrelated tarball missing from
1977
+ // the store, target already relinked" case. The reconcile is simulated by
1978
+ // a stub `pnpm` put first on PATH: it relinks the old copy into
1979
+ // node_modules/good, records the attempt, and exits 1. A direct restored
1980
+ // compatible package must be ACCEPTED: no throw, marker and snapshot
1981
+ // cleared, and exactly ONE reconcile attempt (never an online retry).
1982
+ {
1983
+ const p = join(root, "profiles", "update-accepted");
1984
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
1985
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
1986
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1987
+ writeFileSync(join(p, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n\nimporters: {}\n");
1988
+ // The interrupted update left the NEW version linked in node_modules.
1989
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
1990
+ const binDir = join(root, "stub-bin");
1991
+ mkdirSync(binDir);
1992
+ const attemptsFile = join(root, "stub-attempts.txt");
1993
+ const isWin = process.platform === "win32";
1994
+ const stubPath = join(binDir, isWin ? "pnpm.cmd" : "pnpm");
1995
+ writeFileSync(stubPath, isWin
1996
+ ? `@echo off\r\nmkdir node_modules\\good 2>nul\r\necho {"name":"good","version":"1.0.0"}> node_modules\\good\\package.json\r\necho attempt>> "${attemptsFile}"\r\nexit /b 1\r\n`
1997
+ : `#!/bin/sh\nmkdir -p node_modules/good\nprintf '%s' '{"name":"good","version":"1.0.0"}' > node_modules/good/package.json\necho attempt >> '${attemptsFile}'\nexit 1\n`);
1998
+ if (!isWin) chmodSync(stubPath, 0o755);
1999
+ const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
2000
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
2001
+ const previousPath = process.env.PATH;
2002
+ process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
2003
+ try {
2004
+ rollbackPendingSnapshot(p);
2005
+ } finally {
2006
+ if (previousPath === undefined) delete process.env.PATH;
2007
+ else process.env.PATH = previousPath;
2008
+ }
2009
+ if (readPendingSnapshot(p) !== undefined) throw new Error("a compatible-after-nonzero update rollback must clear the marker");
2010
+ if (existsSync(snap.dir)) throw new Error("a compatible-after-nonzero update rollback must delete the snapshot dir");
2011
+ if (readJson(join(p, "node_modules", "good", "package.json")).version !== "1.0.0") throw new Error("the direct restored package must be the old version");
2012
+ if (readJson(join(p, "package.json")).dependencies?.good !== "1.0.0") throw new Error("the manifest must be restored");
2013
+ const attempts = readFileSync(attemptsFile, "utf8").split(/\r?\n/).filter((line) => line.trim().length > 0);
2014
+ if (attempts.length !== 1) throw new Error(`the rollback must run exactly ONE offline reconcile, got ${attempts.length}`);
2015
+ }
2016
+
2017
+ // The same interrupted update, but the reconcile never relinks the old
2018
+ // copy and only an ANCESTOR node_modules still provides a satisfying
2019
+ // version. Falling back to Node resolution would accept that copy — the
2020
+ // exact unsafe case: the profile itself provides nothing. Rollback must
2021
+ // throw and KEEP marker + snapshot for repair.
2022
+ {
2023
+ const outer = join(root, "outer-only");
2024
+ const p = join(outer, "profiles", "update-outer-only");
2025
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
2026
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" } }));
2027
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2028
+ writeFileSync(join(p, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n\nimporters: {}\n");
2029
+ // The interrupted update left the NEW version linked in node_modules.
2030
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
2031
+ // The satisfying old copy exists ONLY in an ancestor node_modules.
2032
+ mkdirSync(join(outer, "node_modules", "good"), { recursive: true });
2033
+ writeFileSync(join(outer, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
2034
+ const binDir = join(root, "stub-bin-noop");
2035
+ mkdirSync(binDir);
2036
+ const isWin = process.platform === "win32";
2037
+ const stubPath = join(binDir, isWin ? "pnpm.cmd" : "pnpm");
2038
+ writeFileSync(stubPath, isWin ? "@echo off\r\nexit /b 1\r\n" : "#!/bin/sh\nexit 1\n");
2039
+ if (!isWin) chmodSync(stubPath, 0o755);
2040
+ const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
2041
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
2042
+ const previousPath = process.env.PATH;
2043
+ process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
2044
+ let threw = false;
2045
+ try {
2046
+ rollbackPendingSnapshot(p);
2047
+ } catch {
2048
+ threw = true;
2049
+ } finally {
2050
+ if (previousPath === undefined) delete process.env.PATH;
2051
+ else process.env.PATH = previousPath;
2052
+ }
2053
+ if (!threw) throw new Error("a rollback whose direct package is missing must throw even when an ancestor copy satisfies the spec");
2054
+ if (readPendingSnapshot(p)?.id !== snap.id) throw new Error("the failed rollback must KEEP the pending marker");
2055
+ if (!existsSync(snap.dir)) throw new Error("the failed rollback must KEEP the snapshot dir");
2056
+ if (existsSync(join(p, "node_modules", "good"))) throw new Error("the interrupted new version must still be removed");
2057
+ if (readJson(join(p, "package.json")).dependencies?.good !== "1.0.0") throw new Error("the manifest must stay restored even when the rebuild fails");
2058
+ rmSync(pendingPath(p), { force: true });
2059
+ rmSync(snap.dir, { recursive: true, force: true });
2060
+ }
2061
+
2062
+ // ── Targeted fixtures for independent review blockers ──────────────────────
2063
+
2064
+ // 1) Official valid web-style baseline:
2065
+ // Host bundles (@deepseek-ai/dsh-base, @deepseek-ai/dsh-web-app), host-to-host
2066
+ // dependencies, and legitimate in-box multi-mounts must validate clean (safe, 0 blockers).
2067
+ {
2068
+ const p = join(root, "profiles", "web-baseline");
2069
+ mkdirSync(join(p, "node_modules", "@deepseek-ai", "dsh-base"), { recursive: true });
2070
+ mkdirSync(join(p, "node_modules", "@deepseek-ai", "dsh-web-app"), { recursive: true });
2071
+ mkdirSync(join(p, "node_modules", "@deepseek-ai", "dsh-agent"), { recursive: true });
2072
+ mkdirSync(join(p, "node_modules", "@deepseek-ai", "dsh-tool-subagent"), { recursive: true });
2073
+ mkdirSync(join(p, "node_modules", "@deepseek-ai", "dsh-storage"), { recursive: true });
2074
+ mkdirSync(join(p, "node_modules", "@deepseek-ai", "dsh-workspace"), { recursive: true });
2075
+
2076
+ writeFileSync(join(p, "package.json"), JSON.stringify({
2077
+ name: "dsh-profile-web",
2078
+ dependencies: {
2079
+ "@deepseek-ai/dsh-base": "0.1.0-rc.6",
2080
+ "@deepseek-ai/dsh-web-app": "0.1.0-rc.6",
2081
+ },
2082
+ dsh: {
2083
+ profile: {
2084
+ bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"],
2085
+ },
2086
+ },
2087
+ }));
2088
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2089
+
2090
+ writeFileSync(join(p, "node_modules", "@deepseek-ai", "dsh-agent", "package.json"), JSON.stringify({
2091
+ name: "@deepseek-ai/dsh-agent", version: "0.1.0-rc.6",
2092
+ }));
2093
+ writeFileSync(join(p, "node_modules", "@deepseek-ai", "dsh-tool-subagent", "package.json"), JSON.stringify({
2094
+ name: "@deepseek-ai/dsh-tool-subagent", version: "0.1.0-rc.6",
2095
+ }));
2096
+ writeFileSync(join(p, "node_modules", "@deepseek-ai", "dsh-storage", "package.json"), JSON.stringify({
2097
+ name: "@deepseek-ai/dsh-storage", version: "0.1.0-rc.6",
2098
+ }));
2099
+ writeFileSync(join(p, "node_modules", "@deepseek-ai", "dsh-workspace", "package.json"), JSON.stringify({
2100
+ name: "@deepseek-ai/dsh-workspace", version: "0.1.0-rc.6",
2101
+ }));
2102
+
2103
+ writeFileSync(join(p, "node_modules", "@deepseek-ai", "dsh-base", "package.json"), JSON.stringify({
2104
+ name: "@deepseek-ai/dsh-base",
2105
+ version: "0.1.0-rc.6",
2106
+ dependencies: {
2107
+ "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
2108
+ "@deepseek-ai/dsh-tool-subagent": "0.1.0-rc.6",
2109
+ },
2110
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2111
+ }));
2112
+ writeFileSync(join(p, "node_modules", "@deepseek-ai", "dsh-base", "cordis.patch.yml"), `
2113
+ - insert:
2114
+ - id: agent
2115
+ name: '@deepseek-ai/dsh-agent'
2116
+ - id: tool-subagent
2117
+ name: '@deepseek-ai/dsh-tool-subagent'
2118
+ - id: tool-subagent-fork
2119
+ name: '@deepseek-ai/dsh-tool-subagent'
2120
+ `);
2121
+
2122
+ writeFileSync(join(p, "node_modules", "@deepseek-ai", "dsh-web-app", "package.json"), JSON.stringify({
2123
+ name: "@deepseek-ai/dsh-web-app",
2124
+ version: "0.1.0-rc.6",
2125
+ dependencies: {
2126
+ "@deepseek-ai/dsh-storage": "0.1.0-rc.6",
2127
+ "@deepseek-ai/dsh-workspace": "0.1.0-rc.6",
2128
+ },
2129
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2130
+ }));
2131
+ writeFileSync(join(p, "node_modules", "@deepseek-ai", "dsh-web-app", "cordis.patch.yml"), `
2132
+ - insert:
2133
+ - id: storage
2134
+ name: '@deepseek-ai/dsh-storage'
2135
+ - id: workspace
2136
+ name: '@deepseek-ai/dsh-workspace'
2137
+ `);
2138
+
2139
+ const v = validateInstalledProfile(p);
2140
+ if (v.ok !== true || v.verdict !== "safe" || v.issues.filter((e) => e.severity === "block").length > 0) {
2141
+ throw new Error(`official web baseline should validate clean, got issues: ${JSON.stringify(v.issues)}`);
2142
+ }
2143
+ }
2144
+
2145
+ // 2) Real third-party duplicate still blocks:
2146
+ // Third-party conflicts against the official baseline must still fail closed:
2147
+ // A: Third-party shadowing host modules in dependencies
2148
+ // B: Third-party loader ID collision against official bundles
2149
+ // C: Third-party double-mounting an official module
2150
+ {
2151
+ const p = join(root, "profiles", "web-baseline");
2152
+
2153
+ // 2A: Host module shadow
2154
+ const candShadowDir = join(root, "cand-shadow");
2155
+ mkdirSync(candShadowDir, { recursive: true });
2156
+ writeFileSync(join(candShadowDir, "package.json"), JSON.stringify({
2157
+ name: "third-party-shadow",
2158
+ version: "1.0.0",
2159
+ dependencies: { "@deepseek-ai/dsh-agent": "^0.1.0" },
2160
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2161
+ }));
2162
+ writeFileSync(join(candShadowDir, "cordis.patch.yml"), "- insert:\n - id: custom-plug\n name: third-party-shadow\n");
2163
+ const repShadow = inspectCandidate({ profileDir: p, candidateManifestPath: join(candShadowDir, "package.json"), spec: "third-party-shadow" });
2164
+ if (repShadow.verdict !== "blocked" || !repShadow.issues.some((e) => e.code === "host-module-shadow")) {
2165
+ throw new Error("third-party plugin with host dependencies must block with host-module-shadow");
2166
+ }
2167
+
2168
+ // 2B: Loader ID collision
2169
+ const candIdCollDir = join(root, "cand-id-coll");
2170
+ mkdirSync(candIdCollDir, { recursive: true });
2171
+ writeFileSync(join(candIdCollDir, "package.json"), JSON.stringify({
2172
+ name: "third-party-id-coll",
2173
+ version: "1.0.0",
2174
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2175
+ }));
2176
+ writeFileSync(join(candIdCollDir, "cordis.patch.yml"), "- insert:\n - id: tool-subagent\n name: third-party-id-coll\n");
2177
+ const repIdColl = inspectCandidate({ profileDir: p, candidateManifestPath: join(candIdCollDir, "package.json"), spec: "third-party-id-coll" });
2178
+ if (repIdColl.verdict !== "blocked" || !repIdColl.issues.some((e) => e.code === "loader-id-collision")) {
2179
+ throw new Error("third-party plugin colliding on loader ID must block with loader-id-collision");
2180
+ }
2181
+
2182
+ // 2C: Double mount
2183
+ const candDoubleDir = join(root, "cand-double-mount");
2184
+ mkdirSync(candDoubleDir, { recursive: true });
2185
+ writeFileSync(join(candDoubleDir, "package.json"), JSON.stringify({
2186
+ name: "third-party-double-mount",
2187
+ version: "1.0.0",
2188
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2189
+ }));
2190
+ writeFileSync(join(candDoubleDir, "cordis.patch.yml"), "- insert:\n - id: my-storage\n name: '@deepseek-ai/dsh-storage'\n");
2191
+ const repDouble = inspectCandidate({ profileDir: p, candidateManifestPath: join(candDoubleDir, "package.json"), spec: "third-party-double-mount" });
2192
+ if (repDouble.verdict !== "blocked" || !repDouble.issues.some((e) => e.code === "double-mount")) {
2193
+ throw new Error("third-party plugin double-mounting an existing module must block with double-mount");
2194
+ }
2195
+ }
2196
+
2197
+ // 3) Dishonest pending dependencies rejected pre-mutation:
2198
+ // If marker.dependencies disagrees with the protected snapshot package.json,
2199
+ // rollback must fail closed BEFORE touching live files or node_modules.
2200
+ {
2201
+ const p = join(root, "profiles", "dishonest-deps");
2202
+ mkdirSync(join(p, "node_modules", "real-dep"), { recursive: true });
2203
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "real-dep": "1.0.0" } }));
2204
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2205
+ writeFileSync(join(p, "node_modules", "real-dep", "package.json"), JSON.stringify({ name: "real-dep", version: "1.0.0" }));
2206
+ const snap = createProfileSnapshot(p, { fixture: true });
2207
+ markPendingSnapshot(snap, { spec: "new-pkg", preflight: { candidate: { name: "new-pkg", version: "1.0.0" } } });
2208
+ const markerPath = pendingPath(p);
2209
+ const marker = readJson(markerPath);
2210
+ // Tamper marker dependencies so it is dishonest vs snapshot package.json
2211
+ marker.dependencies = ["fake-dep"];
2212
+ writeFileSync(markerPath, JSON.stringify(marker, undefined, 2) + "\n");
2213
+
2214
+ // Mutate live profile as if an install was in progress
2215
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "real-dep": "1.0.0", "new-pkg": "1.0.0" } }));
2216
+ mkdirSync(join(p, "node_modules", "new-pkg"), { recursive: true });
2217
+ writeFileSync(join(p, "node_modules", "new-pkg", "package.json"), JSON.stringify({ name: "new-pkg", version: "1.0.0" }));
2218
+
2219
+ let threw = false;
2220
+ try {
2221
+ rollbackPendingSnapshot(p);
2222
+ } catch {
2223
+ threw = true;
2224
+ }
2225
+ if (!threw) throw new Error("rollbackPendingSnapshot must reject dishonest pending dependencies");
2226
+ if (!existsSync(markerPath)) throw new Error("dishonest marker must be KEPT on disk");
2227
+ if (!existsSync(snap.dir)) throw new Error("dishonest snapshot dir must be KEPT on disk");
2228
+ if (!existsSync(join(p, "node_modules", "new-pkg"))) throw new Error("pre-mutation reject must not touch node_modules");
2229
+ if (!readFileSync(join(p, "package.json"), "utf8").includes("new-pkg")) throw new Error("pre-mutation reject must not restore files before validation");
2230
+ rmSync(markerPath, { force: true });
2231
+ rmSync(snap.dir, { recursive: true, force: true });
2232
+ }
2233
+
2234
+ // 4) Missing collateral restored dep retains evidence:
2235
+ // If a direct dependency declared by the restored manifest is missing in node_modules,
2236
+ // rollback throws and retains evidence.
2237
+ {
2238
+ const p = join(root, "profiles", "missing-collateral");
2239
+ mkdirSync(join(p, "node_modules", "dep-a"), { recursive: true });
2240
+ mkdirSync(join(p, "node_modules", "dep-b"), { recursive: true });
2241
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "dep-a": "1.0.0", "dep-b": "1.0.0" } }));
2242
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2243
+ writeFileSync(join(p, "node_modules", "dep-a", "package.json"), JSON.stringify({ name: "dep-a", version: "1.0.0" }));
2244
+ writeFileSync(join(p, "node_modules", "dep-b", "package.json"), JSON.stringify({ name: "dep-b", version: "1.0.0" }));
2245
+ const snap = createProfileSnapshot(p, { fixture: true });
2246
+ markPendingSnapshot(snap, { spec: "added-pkg", preflight: { candidate: { name: "added-pkg", version: "1.0.0" } } });
2247
+ // Simulate collateral damage: dep-b missing from node_modules
2248
+ rmSync(join(p, "node_modules", "dep-b"), { recursive: true, force: true });
2249
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "dep-a": "1.0.0", "dep-b": "1.0.0", "added-pkg": "1.0.0" } }));
2250
+
2251
+ let threw = false;
2252
+ try {
2253
+ rollbackPendingSnapshot(p);
2254
+ } catch {
2255
+ threw = true;
2256
+ }
2257
+ if (!threw) throw new Error("rollback must throw when a collateral direct dependency is missing in node_modules");
2258
+ if (readPendingSnapshot(p)?.id !== snap.id) throw new Error("missing collateral rollback must KEEP the pending marker");
2259
+ if (!existsSync(snap.dir)) throw new Error("missing collateral rollback must KEEP the snapshot dir");
2260
+ rmSync(pendingPath(p), { force: true });
2261
+ rmSync(snap.dir, { recursive: true, force: true });
2262
+ }
2263
+
2264
+ // 5) Ancestor-only dependency is unresolved:
2265
+ // Direct dependency checks must never fall back to ancestor node_modules.
2266
+ {
2267
+ const outer = join(root, "ancestor-test");
2268
+ const p = join(outer, "profiles", "ancestor-child");
2269
+ mkdirSync(join(outer, "node_modules", "ancestor-pkg"), { recursive: true });
2270
+ writeFileSync(join(outer, "node_modules", "ancestor-pkg", "package.json"), JSON.stringify({ name: "ancestor-pkg", version: "1.0.0" }));
2271
+ mkdirSync(p, { recursive: true });
2272
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "ancestor-pkg": "1.0.0" } }));
2273
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2274
+ const v = validateInstalledProfile(p);
2275
+ if (v.ok !== false || v.verdict !== "blocked" || !v.issues.some((e) => e.code === "package-unresolved")) {
2276
+ throw new Error("validateInstalledProfile must block when dependency only exists in ancestor node_modules");
2277
+ }
2278
+ if (candidateRestoredCompatible(p, "ancestor-pkg", "1.0.0")) {
2279
+ throw new Error("candidateRestoredCompatible must return false when dependency only exists in ancestor node_modules");
2280
+ }
2281
+ }
2282
+
2283
+ // 6) Spoofed official-scoped package candidate (file-spec / local spoof):
2284
+ // A candidate claiming name matching @deepseek-ai/* that is NOT already declared
2285
+ // in current profile (dependencies or bundles) must fail closed (blocked).
2286
+ // A: Spoofed official package carrying host dependencies in dependencies via file spec
2287
+ // B: Spoofed official package without dependencies (blocked as unauthorized new official package)
2288
+ {
2289
+ const p = join(root, "profiles", "web-baseline");
2290
+
2291
+ // 6A: Spoofed candidate with host dependencies via file spec
2292
+ const candSpoofDir = join(root, "cand-spoof-file");
2293
+ mkdirSync(candSpoofDir, { recursive: true });
2294
+ writeFileSync(join(candSpoofDir, "package.json"), JSON.stringify({
2295
+ name: "@deepseek-ai/dsh-fake-extension",
2296
+ version: "1.0.0",
2297
+ dependencies: { "@deepseek-ai/dsh-agent": "^0.1.0" },
2298
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2299
+ }));
2300
+ writeFileSync(join(candSpoofDir, "cordis.patch.yml"), "- insert:\n - id: fake-ext\n name: '@deepseek-ai/dsh-fake-extension'\n");
2301
+ const repSpoof = inspectCandidate({
2302
+ profileDir: p,
2303
+ candidateManifestPath: join(candSpoofDir, "package.json"),
2304
+ spec: "file:./dsh-fake-extension-1.0.0.tgz",
2305
+ });
2306
+ if (repSpoof.ok !== false || repSpoof.verdict !== "blocked") {
2307
+ throw new Error("new candidate spoofing @deepseek-ai/* scope must be blocked");
2308
+ }
2309
+ if (!repSpoof.issues.some((e) => e.code === "official-package-spoof") && !repSpoof.issues.some((e) => e.code === "host-module-shadow")) {
2310
+ throw new Error("spoofed official candidate must report official-package-spoof or host-module-shadow");
2311
+ }
2312
+
2313
+ // 6B: Spoofed candidate without dependencies
2314
+ const candSpoofBareDir = join(root, "cand-spoof-bare");
2315
+ mkdirSync(candSpoofBareDir, { recursive: true });
2316
+ writeFileSync(join(candSpoofBareDir, "package.json"), JSON.stringify({
2317
+ name: "@deepseek-ai/dsh-unauthorized-new",
2318
+ version: "1.0.0",
2319
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2320
+ }));
2321
+ writeFileSync(join(candSpoofBareDir, "cordis.patch.yml"), "- insert:\n - id: unauth-ext\n name: '@deepseek-ai/dsh-unauthorized-new'\n");
2322
+ const repBare = inspectCandidate({
2323
+ profileDir: p,
2324
+ candidateManifestPath: join(candSpoofBareDir, "package.json"),
2325
+ spec: "@deepseek-ai/dsh-unauthorized-new@1.0.0",
2326
+ });
2327
+ if (repBare.ok !== false || repBare.verdict !== "blocked" || !repBare.issues.some((e) => e.code === "official-package-spoof")) {
2328
+ throw new Error("new undeclared @deepseek-ai/* package must be blocked as official-package-spoof");
2329
+ }
2330
+ }
2331
+
2332
+ // 7) Legitimate update of an existing declared official bundle:
2333
+ // When candidate name is an existing declared official bundle (e.g. @deepseek-ai/dsh-base),
2334
+ // carrying host dependencies must NOT trigger false-positive blockers.
2335
+ {
2336
+ const p = join(root, "profiles", "web-baseline");
2337
+ const candUpdateDir = join(root, "cand-official-update");
2338
+ mkdirSync(candUpdateDir, { recursive: true });
2339
+ writeFileSync(join(candUpdateDir, "package.json"), JSON.stringify({
2340
+ name: "@deepseek-ai/dsh-base",
2341
+ version: "0.1.0-rc.7",
2342
+ dependencies: {
2343
+ "@deepseek-ai/dsh-agent": "0.1.0-rc.7",
2344
+ "@deepseek-ai/dsh-tool-subagent": "0.1.0-rc.7",
2345
+ },
2346
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2347
+ }));
2348
+ writeFileSync(join(candUpdateDir, "cordis.patch.yml"), `
2349
+ - insert:
2350
+ - id: agent
2351
+ name: '@deepseek-ai/dsh-agent'
2352
+ - id: tool-subagent
2353
+ name: '@deepseek-ai/dsh-tool-subagent'
2354
+ - id: tool-subagent-fork
2355
+ name: '@deepseek-ai/dsh-tool-subagent'
2356
+ `);
2357
+ const repUpdate = inspectCandidate({
2358
+ profileDir: p,
2359
+ candidateManifestPath: join(candUpdateDir, "package.json"),
2360
+ spec: "@deepseek-ai/dsh-base@0.1.0-rc.7",
2361
+ });
2362
+ if (repUpdate.ok !== true || repUpdate.verdict !== "safe" || repUpdate.issues.some((e) => e.severity === "block")) {
2363
+ throw new Error(`legitimate official update must validate safe with 0 blockers, got: ${JSON.stringify(repUpdate.issues)}`);
2364
+ }
2365
+ }
2366
+
2367
+ // 8) Direct dependency whose package.json omits or mismatches name:
2368
+ // packageInfo and static validation (validateInstalledProfile) must require
2369
+ // manifest.name === requested packageName, flagging missing or mismatched names as package-unresolved.
2370
+ {
2371
+ // 8A: Omitted name in package.json
2372
+ const pMissing = join(root, "profiles", "pkg-omitted-name");
2373
+ mkdirSync(join(pMissing, "node_modules", "no-name-pkg"), { recursive: true });
2374
+ writeFileSync(join(pMissing, "package.json"), JSON.stringify({ dependencies: { "no-name-pkg": "1.0.0" } }));
2375
+ writeFileSync(join(pMissing, "cordis.patch.yml"), "[]\n");
2376
+ writeFileSync(join(pMissing, "node_modules", "no-name-pkg", "package.json"), JSON.stringify({ version: "1.0.0" }));
2377
+
2378
+ if (packageInfo("no-name-pkg", pMissing) !== undefined) {
2379
+ throw new Error("packageInfo must return undefined when package.json omits name");
2380
+ }
2381
+ const vMissing = validateInstalledProfile(pMissing);
2382
+ if (vMissing.ok !== false || vMissing.verdict !== "blocked" || !vMissing.issues.some((e) => e.code === "package-unresolved" && e.package === "no-name-pkg")) {
2383
+ throw new Error("validateInstalledProfile must block with package-unresolved when package.json omits name");
2384
+ }
2385
+
2386
+ // 8B: Mismatched name in package.json
2387
+ const pMismatch = join(root, "profiles", "pkg-mismatched-name");
2388
+ mkdirSync(join(pMismatch, "node_modules", "expected-name"), { recursive: true });
2389
+ writeFileSync(join(pMismatch, "package.json"), JSON.stringify({ dependencies: { "expected-name": "1.0.0" } }));
2390
+ writeFileSync(join(pMismatch, "cordis.patch.yml"), "[]\n");
2391
+ writeFileSync(join(pMismatch, "node_modules", "expected-name", "package.json"), JSON.stringify({ name: "actual-other-name", version: "1.0.0" }));
2392
+
2393
+ if (packageInfo("expected-name", pMismatch) !== undefined) {
2394
+ throw new Error("packageInfo must return undefined when package.json has mismatched name");
2395
+ }
2396
+ const vMismatch = validateInstalledProfile(pMismatch);
2397
+ if (vMismatch.ok !== false || vMismatch.verdict !== "blocked" || !vMismatch.issues.some((e) => e.code === "package-unresolved" && e.package === "expected-name")) {
2398
+ throw new Error("validateInstalledProfile must block with package-unresolved when package.json has mismatched name");
2399
+ }
2400
+ }
2401
+
2402
+ console.log("PASS conflict scan and snapshot/pending/rollback fixtures");
2403
+ } finally {
2404
+ rmSync(root, { recursive: true, force: true });
2405
+ }
2406
+ }
2407
+
2408
+ if (process.argv[1]?.endsWith("guard.js") && process.argv.includes("--self-test")) {
2409
+ selfTest().catch((error) => {
2410
+ console.error(`FAIL ${error.stack ?? error.message}`);
2411
+ process.exitCode = 1;
2412
+ });
2413
+ }