Skip to content

Commit a326faa

Browse files
committed
fix: recover corrupt managed npm installs
1 parent 6467ddd commit a326faa

6 files changed

Lines changed: 378 additions & 18 deletions

File tree

scripts/openclaw-cross-os-release-checks.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,15 @@ async function runUpgradeLane(params) {
10141014
logPath: join(params.logsDir, "upgrade-update-fallback-install.log"),
10151015
ignoreScripts: true,
10161016
});
1017+
const fallbackInstalledVersion = readInstalledVersion(lane.prefixDir);
1018+
verifyWindowsPackagedUpgradeFallbackInstall({
1019+
installedVersion: fallbackInstalledVersion,
1020+
candidateVersion: params.build.candidateVersion,
1021+
});
1022+
appendFileSync(
1023+
updateLogPath,
1024+
`\n[release-checks] Windows fallback install verified candidate version ${fallbackInstalledVersion}\n`,
1025+
);
10171026
});
10181027
} else {
10191028
verifyPackagedUpgradeUpdateResult(updateResult, {
@@ -1632,6 +1641,17 @@ export function shouldRunPackagedUpgradeStatusProbe({
16321641
return !(platform === "win32" && usedWindowsPackagedUpgradeFallback);
16331642
}
16341643

1644+
export function verifyWindowsPackagedUpgradeFallbackInstall({
1645+
installedVersion,
1646+
candidateVersion,
1647+
}) {
1648+
if (installedVersion !== candidateVersion) {
1649+
throw new Error(
1650+
`Windows packaged upgrade fallback installed ${installedVersion || "unknown"}, expected ${candidateVersion}`,
1651+
);
1652+
}
1653+
}
1654+
16351655
export function resolveExplicitBaselineVersion(baselineSpec) {
16361656
const trimmed = baselineSpec.trim();
16371657
if (!trimmed || trimmed === "openclaw@latest") {

src/infra/package-update-steps.test.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import {
77
runGlobalPackageUpdateSteps,
88
type PackageUpdateStepResult,
99
} from "./package-update-steps.js";
10-
import type { CommandRunner, ResolvedGlobalInstallTarget } from "./update-global.js";
10+
import {
11+
resolveNpmGlobalPrefixLayoutFromPrefix,
12+
type CommandRunner,
13+
type ResolvedGlobalInstallTarget,
14+
} from "./update-global.js";
1115

1216
async function writePackageRoot(packageRoot: string, version: string): Promise<void> {
1317
await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
@@ -526,10 +530,8 @@ describe("runGlobalPackageUpdateSteps", () => {
526530
if (!stagePrefix) {
527531
throw new Error("missing staged prefix");
528532
}
529-
await writePackageRoot(
530-
path.join(stagePrefix, "lib", "node_modules", "openclaw"),
531-
"2.0.0",
532-
);
533+
const stageLayout = resolveNpmGlobalPrefixLayoutFromPrefix(stagePrefix);
534+
await writePackageRoot(path.join(stageLayout.globalRoot, "openclaw"), "2.0.0");
533535
return {
534536
name,
535537
command: argv.join(" "),
@@ -685,10 +687,8 @@ describe("runGlobalPackageUpdateSteps", () => {
685687
if (!stagePrefix) {
686688
throw new Error("missing staged prefix");
687689
}
688-
await writePackageRoot(
689-
path.join(stagePrefix, "lib", "node_modules", "openclaw"),
690-
"2.0.0",
691-
);
690+
const stageLayout = resolveNpmGlobalPrefixLayoutFromPrefix(stagePrefix);
691+
await writePackageRoot(path.join(stageLayout.globalRoot, "openclaw"), "2.0.0");
692692
return {
693693
name,
694694
command: argv.join(" "),
@@ -702,6 +702,12 @@ describe("runGlobalPackageUpdateSteps", () => {
702702

703703
expect(result.failedStep).toBeNull();
704704
expect(result.afterVersion).toBe("2.0.0");
705+
const swapStep = result.steps.find((step) => step.name === "global install swap");
706+
expect(swapStep?.stdoutTail).toContain("preserved old package");
707+
const delayedCleanupDirs = (await fs.readdir(globalRoot)).filter((entry) =>
708+
entry.startsWith(".openclaw-"),
709+
);
710+
expect(delayedCleanupDirs).toHaveLength(1);
705711
await expect(
706712
fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
707713
).resolves.toContain('"version":"2.0.0"');

src/infra/package-update-steps.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,18 @@ function formatError(err: unknown): string {
6060
return err instanceof Error ? err.message : String(err);
6161
}
6262

63-
async function removePathBestEffort(targetPath: string): Promise<void> {
64-
await fs
65-
.rm(targetPath, {
63+
async function removePathBestEffort(targetPath: string): Promise<boolean> {
64+
try {
65+
await fs.rm(targetPath, {
6666
recursive: true,
6767
force: true,
6868
maxRetries: process.platform === "win32" ? 5 : 2,
6969
retryDelay: 100,
70-
})
71-
.catch(() => undefined);
70+
});
71+
return true;
72+
} catch {
73+
return false;
74+
}
7275
}
7376

7477
async function readPackageVersionIfPresent(packageRoot: string | null): Promise<string | null> {
@@ -420,6 +423,7 @@ async function swapStagedNpmInstall(params: {
420423
const backupRoot = path.join(targetLayout.globalRoot, `.openclaw-${process.pid}-${Date.now()}`);
421424
let movedExisting = false;
422425
let movedStaged = false;
426+
let removedBackup = true;
423427
try {
424428
await fs.mkdir(targetLayout.globalRoot, { recursive: true });
425429
if (await pathExists(targetPackageRoot)) {
@@ -444,7 +448,7 @@ async function swapStagedNpmInstall(params: {
444448
});
445449
}
446450
if (movedExisting) {
447-
await removePathBestEffort(backupRoot);
451+
removedBackup = await removePathBestEffort(backupRoot);
448452
}
449453
return {
450454
name: "global install swap",
@@ -453,7 +457,9 @@ async function swapStagedNpmInstall(params: {
453457
durationMs: Date.now() - startedAt,
454458
exitCode: 0,
455459
stdoutTail: movedExisting
456-
? `replaced ${params.packageName}`
460+
? removedBackup
461+
? `replaced ${params.packageName}`
462+
: `replaced ${params.packageName}; preserved old package at ${backupRoot} for delayed cleanup`
457463
: `installed ${params.packageName}`,
458464
stderrTail: null,
459465
};

src/plugins/install.npm-spec.test.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ function successfulSpawn(stdout = "") {
5454
};
5555
}
5656

57+
function failedSpawn(stderr: string, stdout = "") {
58+
return {
59+
code: 1,
60+
stdout,
61+
stderr,
62+
signal: null,
63+
killed: false,
64+
termination: "exit" as const,
65+
};
66+
}
67+
5768
function npmViewArgv(spec: string): string[] {
5869
return [
5970
"npm",
@@ -941,6 +952,198 @@ describe("installPluginFromNpmSpec", () => {
941952
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "missing-lock-plugin"))).toBe(false);
942953
});
943954

955+
it("quarantines and rebuilds a corrupt managed npm project after npm from-argument failures", async () => {
956+
const stateDir = suiteTempRootTracker.makeTempDir();
957+
const npmRoot = path.join(stateDir, "npm");
958+
const packageName = "@openclaw/voice-call";
959+
const warnings: string[] = [];
960+
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
961+
const stalePackageDir = path.join(npmProjectRoot, "node_modules", "stale-plugin");
962+
fs.mkdirSync(stalePackageDir, { recursive: true });
963+
fs.writeFileSync(path.join(stalePackageDir, "stale.txt"), "old tree", "utf8");
964+
fs.writeFileSync(
965+
path.join(npmProjectRoot, "package-lock.json"),
966+
`${JSON.stringify({ lockfileVersion: 3, packages: {} })}\n`,
967+
"utf8",
968+
);
969+
970+
mockNpmViewAndInstall({
971+
spec: `${packageName}@1.0.0`,
972+
packageName,
973+
version: "1.0.0",
974+
pluginId: "voice-call",
975+
npmRoot,
976+
expectedDependencySpec: "1.0.0",
977+
});
978+
const delegate = runCommandWithTimeoutMock.getMockImplementation();
979+
if (!delegate) {
980+
throw new Error("expected npm mock implementation");
981+
}
982+
let managedInstallAttempts = 0;
983+
runCommandWithTimeoutMock.mockImplementation(
984+
async (argv: string[], options?: { cwd?: string }) => {
985+
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
986+
managedInstallAttempts += 1;
987+
if (managedInstallAttempts === 1) {
988+
return failedSpawn(
989+
'npm ERR! code ERR_INVALID_ARG_TYPE\nnpm ERR! The "from" argument must be of type string. Received undefined',
990+
);
991+
}
992+
}
993+
return await delegate(argv, options);
994+
},
995+
);
996+
997+
const result = await installPluginFromNpmSpec({
998+
spec: `${packageName}@1.0.0`,
999+
npmDir: npmRoot,
1000+
logger: { info: () => {}, warn: (message) => warnings.push(message) },
1001+
});
1002+
1003+
expect(result.ok).toBe(true);
1004+
if (!result.ok) {
1005+
return;
1006+
}
1007+
expect(managedInstallAttempts).toBe(2);
1008+
expect(result.pluginId).toBe("voice-call");
1009+
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(true);
1010+
expect(warnings.some((warning) => warning.includes("managed npm project corruption"))).toBe(
1011+
true,
1012+
);
1013+
const quarantineParent = path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects");
1014+
const quarantines = fs.readdirSync(quarantineParent);
1015+
expect(quarantines).toHaveLength(1);
1016+
const quarantineDir = path.join(quarantineParent, quarantines[0] ?? "");
1017+
expect(
1018+
fs.readFileSync(
1019+
path.join(quarantineDir, "node_modules", "stale-plugin", "stale.txt"),
1020+
"utf8",
1021+
),
1022+
).toBe("old tree");
1023+
expect(fs.existsSync(path.join(quarantineDir, "package-lock.json"))).toBe(true);
1024+
});
1025+
1026+
it("scans rebuilt hoisted dependencies after managed npm project quarantine", async () => {
1027+
const stateDir = suiteTempRootTracker.makeTempDir();
1028+
const npmRoot = path.join(stateDir, "npm");
1029+
const packageName = "unsafe-rebuild-plugin";
1030+
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
1031+
fs.mkdirSync(path.join(npmProjectRoot, "node_modules", "plain-crypto-js"), {
1032+
recursive: true,
1033+
});
1034+
1035+
mockNpmViewAndInstall({
1036+
spec: `${packageName}@1.0.0`,
1037+
packageName,
1038+
version: "1.0.0",
1039+
pluginId: packageName,
1040+
npmRoot,
1041+
expectedDependencySpec: "1.0.0",
1042+
hoistedDependency: { name: "plain-crypto-js", version: "1.0.0" },
1043+
});
1044+
const delegate = runCommandWithTimeoutMock.getMockImplementation();
1045+
if (!delegate) {
1046+
throw new Error("expected npm mock implementation");
1047+
}
1048+
let managedInstallAttempts = 0;
1049+
runCommandWithTimeoutMock.mockImplementation(
1050+
async (argv: string[], options?: { cwd?: string }) => {
1051+
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
1052+
managedInstallAttempts += 1;
1053+
if (managedInstallAttempts === 1) {
1054+
return failedSpawn(
1055+
'npm ERR! code ERR_INVALID_ARG_TYPE\nnpm ERR! The "from" argument must be of type string. Received undefined',
1056+
);
1057+
}
1058+
}
1059+
return await delegate(argv, options);
1060+
},
1061+
);
1062+
1063+
const result = await installPluginFromNpmSpec({
1064+
spec: `${packageName}@1.0.0`,
1065+
npmDir: npmRoot,
1066+
logger: { info: () => {}, warn: () => {} },
1067+
});
1068+
1069+
expect(result.ok).toBe(false);
1070+
if (!result.ok) {
1071+
expect(result.error).toContain("plain-crypto-js");
1072+
}
1073+
expect(managedInstallAttempts).toBe(2);
1074+
});
1075+
1076+
it("keeps corrupt managed npm project artifacts quarantined when the rebuild retry fails", async () => {
1077+
const stateDir = suiteTempRootTracker.makeTempDir();
1078+
const npmRoot = path.join(stateDir, "npm");
1079+
const packageName = "broken-plugin";
1080+
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
1081+
fs.mkdirSync(path.join(npmProjectRoot, "node_modules", "stale-plugin"), { recursive: true });
1082+
fs.writeFileSync(
1083+
path.join(npmProjectRoot, "node_modules", "stale-plugin", "stale.txt"),
1084+
"old tree",
1085+
"utf8",
1086+
);
1087+
1088+
mockNpmViewAndInstall({
1089+
spec: `${packageName}@1.0.0`,
1090+
packageName,
1091+
version: "1.0.0",
1092+
pluginId: packageName,
1093+
npmRoot,
1094+
expectedDependencySpec: "1.0.0",
1095+
});
1096+
const delegate = runCommandWithTimeoutMock.getMockImplementation();
1097+
if (!delegate) {
1098+
throw new Error("expected npm mock implementation");
1099+
}
1100+
let managedInstallAttempts = 0;
1101+
runCommandWithTimeoutMock.mockImplementation(
1102+
async (argv: string[], options?: { cwd?: string }) => {
1103+
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
1104+
managedInstallAttempts += 1;
1105+
if (managedInstallAttempts === 1) {
1106+
return failedSpawn(
1107+
'npm ERR! code ERR_INVALID_ARG_TYPE\nnpm ERR! The "from" argument must be of type string. Received undefined',
1108+
);
1109+
}
1110+
return failedSpawn("npm ERR! still broken");
1111+
}
1112+
return await delegate(argv, options);
1113+
},
1114+
);
1115+
1116+
const result = await installPluginFromNpmSpec({
1117+
spec: `${packageName}@1.0.0`,
1118+
npmDir: npmRoot,
1119+
logger: { info: () => {}, warn: () => {} },
1120+
});
1121+
1122+
expect(result.ok).toBe(false);
1123+
if (result.ok) {
1124+
return;
1125+
}
1126+
expect(managedInstallAttempts).toBeGreaterThanOrEqual(2);
1127+
expect(result.error).toContain("npm install failed after managed npm project recovery");
1128+
expect(result.error).toContain("Original npm error");
1129+
const quarantineParent = path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects");
1130+
const quarantines = fs.readdirSync(quarantineParent);
1131+
expect(quarantines).toHaveLength(1);
1132+
expect(
1133+
fs.readFileSync(
1134+
path.join(
1135+
quarantineParent,
1136+
quarantines[0] ?? "",
1137+
"node_modules",
1138+
"stale-plugin",
1139+
"stale.txt",
1140+
),
1141+
"utf8",
1142+
),
1143+
).toBe("old tree");
1144+
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(false);
1145+
});
1146+
9441147
it("rejects npm installs with blocked hoisted transitive dependencies", async () => {
9451148
const stateDir = suiteTempRootTracker.makeTempDir();
9461149
const npmRoot = path.join(stateDir, "npm");

0 commit comments

Comments
 (0)