mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: recover managed npm lock metadata (#107294)
This commit is contained in:
committed by
Peter Steinberger
parent
311d9bcc5e
commit
0129eb5916
@@ -0,0 +1,46 @@
|
||||
import type { NpmSpecResolution } from "../infra/install-source-utils.js";
|
||||
import type { ManagedNpmRootInstalledDependency } from "../infra/npm-managed-root.js";
|
||||
|
||||
type InstalledNpmResolutionVerification =
|
||||
| { kind: "ok" }
|
||||
| { kind: "incomplete"; error: string }
|
||||
| { kind: "conflict"; error: string };
|
||||
|
||||
export function verifyInstalledNpmResolution(params: {
|
||||
packageName: string;
|
||||
expected: NpmSpecResolution;
|
||||
installed: ManagedNpmRootInstalledDependency | null;
|
||||
}): InstalledNpmResolutionVerification {
|
||||
if (!params.installed) {
|
||||
return {
|
||||
kind: "incomplete",
|
||||
error: `npm install did not record package-lock metadata for ${params.packageName}`,
|
||||
};
|
||||
}
|
||||
if (params.expected.version && params.installed.version) {
|
||||
if (params.installed.version !== params.expected.version) {
|
||||
return {
|
||||
kind: "conflict",
|
||||
error: `npm install resolved ${params.packageName} to version ${params.installed.version}, expected ${params.expected.version}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (params.expected.integrity && params.installed.integrity) {
|
||||
if (params.installed.integrity !== params.expected.integrity) {
|
||||
return {
|
||||
kind: "conflict",
|
||||
error: `npm install resolved ${params.packageName} with integrity ${params.installed.integrity}, expected ${params.expected.integrity}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
(params.expected.version && !params.installed.version) ||
|
||||
(params.expected.integrity && !params.installed.integrity)
|
||||
) {
|
||||
return {
|
||||
kind: "incomplete",
|
||||
error: `npm install recorded incomplete package-lock metadata for ${params.packageName}: ${params.expected.version && !params.installed.version ? "version" : "integrity"} missing`,
|
||||
};
|
||||
}
|
||||
return { kind: "ok" };
|
||||
}
|
||||
@@ -120,6 +120,13 @@ function isManagedNpmInstallCommand(argv: unknown): argv is string[] {
|
||||
return isNpmInstallCommand(argv) && !isNpmPeerPlannerInstallCommand(argv);
|
||||
}
|
||||
|
||||
function managedNpmRootHasDependency(npmRoot: string, packageName: string): boolean {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
return packageName in (manifest.dependencies ?? {});
|
||||
}
|
||||
|
||||
function expectNpmInstallIntoRoot(params: {
|
||||
calls: unknown[][];
|
||||
npmRoot: string;
|
||||
@@ -302,6 +309,8 @@ type MockNpmPackage = {
|
||||
versions?: string[];
|
||||
installedVersion?: string;
|
||||
installedIntegrity?: string;
|
||||
omitInstalledVersion?: boolean;
|
||||
omitInstalledIntegrity?: boolean;
|
||||
materializesRootOpenClaw?: boolean;
|
||||
skipLockfileEntry?: boolean;
|
||||
packArchivePath?: string;
|
||||
@@ -324,8 +333,10 @@ function writeNpmRootPackageLock(params: {
|
||||
continue;
|
||||
}
|
||||
lockPackages[`node_modules/${pkg.packageName}`] = {
|
||||
version: pkg.installedVersion ?? pkg.version,
|
||||
integrity: pkg.installedIntegrity ?? pkg.integrity ?? "sha512-plugin-test",
|
||||
...(pkg.omitInstalledVersion ? {} : { version: pkg.installedVersion ?? pkg.version }),
|
||||
...(pkg.omitInstalledIntegrity
|
||||
? {}
|
||||
: { integrity: pkg.installedIntegrity ?? pkg.integrity ?? "sha512-plugin-test" }),
|
||||
};
|
||||
if (pkg.materializesRootOpenClaw) {
|
||||
lockPackages["node_modules/openclaw"] = {
|
||||
@@ -1205,6 +1216,10 @@ describe("installPluginFromNpmSpec", () => {
|
||||
|
||||
it("rejects npm installs when the installed artifact drifts from verified metadata", async () => {
|
||||
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "drift-plugin",
|
||||
});
|
||||
mockNpmViewAndInstall({
|
||||
spec: "drift-plugin@latest",
|
||||
packageName: "drift-plugin",
|
||||
@@ -1216,6 +1231,21 @@ describe("installPluginFromNpmSpec", () => {
|
||||
npmRoot,
|
||||
expectedDependencySpec: "1.0.0",
|
||||
});
|
||||
const delegate = runCommandWithTimeoutMock.getMockImplementation();
|
||||
if (!delegate) {
|
||||
throw new Error("expected npm mock implementation");
|
||||
}
|
||||
let managedInstallAttempts = 0;
|
||||
runCommandWithTimeoutMock.mockImplementation(async (argv, options) => {
|
||||
if (
|
||||
isManagedNpmInstallCommand(argv) &&
|
||||
options?.cwd === npmProjectRoot &&
|
||||
managedNpmRootHasDependency(npmProjectRoot, "drift-plugin")
|
||||
) {
|
||||
managedInstallAttempts += 1;
|
||||
}
|
||||
return await delegate(argv, options);
|
||||
});
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: "drift-plugin@latest",
|
||||
@@ -1230,11 +1260,19 @@ describe("installPluginFromNpmSpec", () => {
|
||||
}
|
||||
expect(result.error).toContain("integrity sha512-evil");
|
||||
expect(result.error).toContain("expected sha512-safe");
|
||||
expect(managedInstallAttempts).toBe(1);
|
||||
expect(fs.existsSync(path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "drift-plugin"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects npm installs when the installed version drifts from verified metadata", async () => {
|
||||
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "version-drift-plugin",
|
||||
});
|
||||
mockNpmViewAndInstall({
|
||||
spec: "version-drift-plugin@latest",
|
||||
packageName: "version-drift-plugin",
|
||||
@@ -1244,6 +1282,17 @@ describe("installPluginFromNpmSpec", () => {
|
||||
npmRoot,
|
||||
expectedDependencySpec: "1.0.0",
|
||||
});
|
||||
const delegate = runCommandWithTimeoutMock.getMockImplementation();
|
||||
if (!delegate) {
|
||||
throw new Error("expected npm mock implementation");
|
||||
}
|
||||
let managedInstallAttempts = 0;
|
||||
runCommandWithTimeoutMock.mockImplementation(async (argv, options) => {
|
||||
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
|
||||
managedInstallAttempts += 1;
|
||||
}
|
||||
return await delegate(argv, options);
|
||||
});
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: "version-drift-plugin@latest",
|
||||
@@ -1257,11 +1306,236 @@ describe("installPluginFromNpmSpec", () => {
|
||||
}
|
||||
expect(result.error).toContain("version 1.0.1");
|
||||
expect(result.error).toContain("expected 1.0.0");
|
||||
expect(managedInstallAttempts).toBe(1);
|
||||
expect(fs.existsSync(path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "version-drift-plugin"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects npm installs when package-lock omits the installed plugin", async () => {
|
||||
it("quarantines incomplete integrity metadata and rebuilds the managed project once", async () => {
|
||||
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const packageName = "missing-integrity-plugin";
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
|
||||
const fixture: MockNpmPackage & { spec: string } = {
|
||||
spec: `${packageName}@latest`,
|
||||
packageName,
|
||||
version: "1.0.0",
|
||||
pluginId: packageName,
|
||||
integrity: "sha512-safe",
|
||||
omitInstalledIntegrity: true,
|
||||
npmRoot,
|
||||
expectedDependencySpec: "1.0.0",
|
||||
};
|
||||
mockNpmViewAndInstall(fixture);
|
||||
const delegate = runCommandWithTimeoutMock.getMockImplementation();
|
||||
if (!delegate) {
|
||||
throw new Error("expected npm mock implementation");
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
let managedInstallAttempts = 0;
|
||||
runCommandWithTimeoutMock.mockImplementation(async (argv, options) => {
|
||||
if (
|
||||
isManagedNpmInstallCommand(argv) &&
|
||||
options?.cwd === npmProjectRoot &&
|
||||
managedNpmRootHasDependency(npmProjectRoot, packageName)
|
||||
) {
|
||||
managedInstallAttempts += 1;
|
||||
if (managedInstallAttempts === 2) {
|
||||
fixture.omitInstalledIntegrity = false;
|
||||
}
|
||||
}
|
||||
return await delegate(argv, options);
|
||||
});
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: fixture.spec,
|
||||
expectedIntegrity: fixture.integrity,
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: (message) => warnings.push(message) },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(managedInstallAttempts).toBe(2);
|
||||
expect(warnings.some((warning) => warning.includes("integrity missing"))).toBe(true);
|
||||
const installed = JSON.parse(
|
||||
fs.readFileSync(path.join(npmProjectRoot, "package-lock.json"), "utf8"),
|
||||
) as { packages?: Record<string, { integrity?: string }> };
|
||||
expect(installed.packages?.[`node_modules/${packageName}`]?.integrity).toBe("sha512-safe");
|
||||
expect(
|
||||
fs.readdirSync(path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["integrity", "version"] as const)(
|
||||
"fails closed when rebuilt package-lock metadata still omits %s",
|
||||
async (missingField) => {
|
||||
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const packageName = "persistently-incomplete-metadata-plugin";
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
|
||||
mockNpmViewAndInstall({
|
||||
spec: `${packageName}@latest`,
|
||||
packageName,
|
||||
version: "1.0.0",
|
||||
pluginId: packageName,
|
||||
integrity: "sha512-safe",
|
||||
omitInstalledIntegrity: missingField === "integrity",
|
||||
omitInstalledVersion: missingField === "version",
|
||||
npmRoot,
|
||||
expectedDependencySpec: "1.0.0",
|
||||
});
|
||||
const delegate = runCommandWithTimeoutMock.getMockImplementation();
|
||||
if (!delegate) {
|
||||
throw new Error("expected npm mock implementation");
|
||||
}
|
||||
let managedInstallAttempts = 0;
|
||||
runCommandWithTimeoutMock.mockImplementation(async (argv, options) => {
|
||||
if (
|
||||
isManagedNpmInstallCommand(argv) &&
|
||||
options?.cwd === npmProjectRoot &&
|
||||
managedNpmRootHasDependency(npmProjectRoot, packageName)
|
||||
) {
|
||||
managedInstallAttempts += 1;
|
||||
}
|
||||
return await delegate(argv, options);
|
||||
});
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: `${packageName}@latest`,
|
||||
expectedIntegrity: "sha512-safe",
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(managedInstallAttempts).toBe(2);
|
||||
expect(result.error).toContain(
|
||||
"metadata remained incomplete after managed npm project recovery",
|
||||
);
|
||||
expect(result.error).toContain(`${missingField} missing`);
|
||||
expect(
|
||||
fs.readdirSync(path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects")),
|
||||
).toHaveLength(1);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not restore a quarantined tree when post-recovery validation fails", async () => {
|
||||
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const packageName = "unsafe-recovered-plugin";
|
||||
const addedPeerName = "recovery-added-peer";
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
|
||||
const stalePackageDir = path.join(npmProjectRoot, "node_modules", "stale-plugin");
|
||||
fs.mkdirSync(stalePackageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stalePackageDir, "stale.txt"), "poisoned tree", "utf8");
|
||||
const fixture: MockNpmPackage & { spec: string } = {
|
||||
spec: `${packageName}@latest`,
|
||||
packageName,
|
||||
version: "1.0.0",
|
||||
pluginId: packageName,
|
||||
integrity: "sha512-safe",
|
||||
omitInstalledIntegrity: true,
|
||||
npmRoot,
|
||||
expectedDependencySpec: "1.0.0",
|
||||
hoistedDependency: { name: "plain-crypto-js", version: "1.0.0" },
|
||||
};
|
||||
mockNpmViewAndInstallMany([
|
||||
fixture,
|
||||
{
|
||||
packageName: addedPeerName,
|
||||
version: "2.0.0",
|
||||
npmRoot,
|
||||
},
|
||||
]);
|
||||
const delegate = runCommandWithTimeoutMock.getMockImplementation();
|
||||
if (!delegate) {
|
||||
throw new Error("expected npm mock implementation");
|
||||
}
|
||||
let managedInstallAttempts = 0;
|
||||
runCommandWithTimeoutMock.mockImplementation(async (argv, options) => {
|
||||
if (
|
||||
isManagedNpmInstallCommand(argv) &&
|
||||
options?.cwd === npmProjectRoot &&
|
||||
managedNpmRootHasDependency(npmProjectRoot, packageName)
|
||||
) {
|
||||
managedInstallAttempts += 1;
|
||||
if (managedInstallAttempts === 2) {
|
||||
fixture.omitInstalledIntegrity = false;
|
||||
}
|
||||
}
|
||||
return await delegate(argv, options);
|
||||
});
|
||||
let mutatedPeerAfterQuarantine = false;
|
||||
const addPeerAfterQuarantine = () => {
|
||||
const manifestPath = path.join(npmProjectRoot, "package.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
};
|
||||
manifest.dependencies ??= {};
|
||||
manifest.dependencies[addedPeerName] = "2.0.0";
|
||||
manifest.openclaw ??= {};
|
||||
manifest.openclaw.managedPeerDependencies = [addedPeerName];
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
mutatedPeerAfterQuarantine = true;
|
||||
};
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: fixture.spec,
|
||||
expectedIntegrity: fixture.integrity,
|
||||
npmDir: npmRoot,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: (message) => {
|
||||
if (message.includes("quarantined")) {
|
||||
addPeerAfterQuarantine();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(managedInstallAttempts).toBe(2);
|
||||
expect(mutatedPeerAfterQuarantine).toBe(true);
|
||||
const quarantineParent = path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects");
|
||||
const quarantines = fs.readdirSync(quarantineParent);
|
||||
expect(quarantines).toHaveLength(1);
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
quarantineParent,
|
||||
quarantines[0] ?? "",
|
||||
"node_modules",
|
||||
"stale-plugin",
|
||||
"stale.txt",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("poisoned tree");
|
||||
expect(fs.existsSync(stalePackageDir)).toBe(false);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
};
|
||||
expect(manifest.dependencies?.[addedPeerName]).toBeUndefined();
|
||||
expect(manifest.openclaw?.managedPeerDependencies ?? []).not.toContain(addedPeerName);
|
||||
});
|
||||
|
||||
it("quarantines and retries once when package-lock omits the installed plugin", async () => {
|
||||
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "missing-lock-plugin",
|
||||
});
|
||||
mockNpmViewAndInstall({
|
||||
spec: "missing-lock-plugin@latest",
|
||||
packageName: "missing-lock-plugin",
|
||||
@@ -1271,6 +1545,21 @@ describe("installPluginFromNpmSpec", () => {
|
||||
expectedDependencySpec: "1.0.0",
|
||||
skipLockfileEntry: true,
|
||||
});
|
||||
const delegate = runCommandWithTimeoutMock.getMockImplementation();
|
||||
if (!delegate) {
|
||||
throw new Error("expected npm mock implementation");
|
||||
}
|
||||
let managedInstallAttempts = 0;
|
||||
runCommandWithTimeoutMock.mockImplementation(async (argv, options) => {
|
||||
if (
|
||||
isManagedNpmInstallCommand(argv) &&
|
||||
options?.cwd === npmProjectRoot &&
|
||||
managedNpmRootHasDependency(npmProjectRoot, "missing-lock-plugin")
|
||||
) {
|
||||
managedInstallAttempts += 1;
|
||||
}
|
||||
return await delegate(argv, options);
|
||||
});
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: "missing-lock-plugin@latest",
|
||||
@@ -1285,6 +1574,10 @@ describe("installPluginFromNpmSpec", () => {
|
||||
expect(result.error).toContain(
|
||||
"npm install did not record package-lock metadata for missing-lock-plugin",
|
||||
);
|
||||
expect(managedInstallAttempts).toBe(2);
|
||||
expect(
|
||||
fs.readdirSync(path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects")),
|
||||
).toHaveLength(1);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "missing-lock-plugin"))).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
+66
-73
@@ -51,6 +51,7 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { InstallPolicySource } from "../security/install-policy.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { verifyInstalledNpmResolution } from "./install-npm-resolution.js";
|
||||
import {
|
||||
encodePluginInstallDirName,
|
||||
matchesExpectedPluginId,
|
||||
@@ -1038,23 +1039,6 @@ async function quarantineManagedNpmProjectRebuildArtifacts(params: {
|
||||
return { quarantineDir, movedArtifactNames };
|
||||
}
|
||||
|
||||
function resolveInstalledNpmResolutionMismatch(params: {
|
||||
packageName: string;
|
||||
expected: NpmSpecResolution;
|
||||
installed: ManagedNpmRootInstalledDependency | null;
|
||||
}): string | null {
|
||||
if (!params.installed) {
|
||||
return `npm install did not record package-lock metadata for ${params.packageName}`;
|
||||
}
|
||||
if (params.expected.version && params.installed.version !== params.expected.version) {
|
||||
return `npm install resolved ${params.packageName} to version ${params.installed.version ?? "unknown"}, expected ${params.expected.version}`;
|
||||
}
|
||||
if (params.expected.integrity && params.installed.integrity !== params.expected.integrity) {
|
||||
return `npm install resolved ${params.packageName} with integrity ${params.installed.integrity ?? "unknown"}, expected ${params.expected.integrity}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listManagedNpmRootPackageNames(npmRoot: string): Promise<Set<string>> {
|
||||
const nodeModulesDir = path.join(npmRoot, "node_modules");
|
||||
let entries: Dirent[];
|
||||
@@ -1417,6 +1401,15 @@ async function installPluginFromManagedNpmRoot(
|
||||
|
||||
let rollbackSnapshot: ManagedNpmPluginInstallRollbackSnapshot;
|
||||
let preparedDependency: ManagedNpmRootPreparedDependency | undefined;
|
||||
let rollbackPeerDependencySnapshot:
|
||||
| Awaited<ReturnType<typeof readManagedNpmRootPeerDependencySnapshot>>
|
||||
| undefined;
|
||||
let recovery:
|
||||
| {
|
||||
cause: { kind: "npm-corruption" | "incomplete-metadata"; error: string };
|
||||
quarantine: ManagedNpmProjectQuarantine;
|
||||
}
|
||||
| undefined;
|
||||
try {
|
||||
rollbackSnapshot = await createManagedNpmPluginInstallRollbackSnapshot({ npmRoot });
|
||||
} catch (error) {
|
||||
@@ -1440,11 +1433,8 @@ async function installPluginFromManagedNpmRoot(
|
||||
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`);
|
||||
}
|
||||
}
|
||||
let preInstallRootPackageNames = await listManagedNpmRootPackageNames(npmRoot);
|
||||
const managedOverrides = await readOpenClawManagedNpmRootOverrides();
|
||||
const rollbackPeerDependencySnapshot = await readManagedNpmRootPeerDependencySnapshot({
|
||||
npmRoot,
|
||||
});
|
||||
rollbackPeerDependencySnapshot ??= await readManagedNpmRootPeerDependencySnapshot({ npmRoot });
|
||||
const rollbackFailedManagedNpmInstall = async (
|
||||
failure: Extract<InstallPluginResult, { ok: false }>,
|
||||
): Promise<Extract<InstallPluginResult, { ok: false }>> => {
|
||||
@@ -1455,7 +1445,9 @@ async function installPluginFromManagedNpmRoot(
|
||||
timeoutMs,
|
||||
logger,
|
||||
peerDependencySnapshot: rollbackPeerDependencySnapshot,
|
||||
snapshot: rollbackSnapshot,
|
||||
// Once the poisoned tree has been quarantined, restoring this snapshot
|
||||
// would recreate the crash loop that the recovery attempt is repairing.
|
||||
snapshot: recovery ? undefined : rollbackSnapshot,
|
||||
});
|
||||
await rollbackManagedNpmRootPreparedDependency({
|
||||
packageName: params.packageName,
|
||||
@@ -1464,23 +1456,22 @@ async function installPluginFromManagedNpmRoot(
|
||||
});
|
||||
return failure;
|
||||
};
|
||||
const rollbackFailedManagedNpmInstallAfterQuarantine = async (
|
||||
failure: Extract<InstallPluginResult, { ok: false }>,
|
||||
): Promise<Extract<InstallPluginResult, { ok: false }>> => {
|
||||
await rollbackManagedNpmPluginInstall({
|
||||
npmRoot,
|
||||
packageName: params.packageName,
|
||||
targetDir: installRoot,
|
||||
timeoutMs,
|
||||
logger,
|
||||
peerDependencySnapshot: rollbackPeerDependencySnapshot,
|
||||
});
|
||||
await rollbackManagedNpmRootPreparedDependency({
|
||||
packageName: params.packageName,
|
||||
preparedDependency: prepared,
|
||||
logger,
|
||||
});
|
||||
return failure;
|
||||
const quarantineForRecovery = async (
|
||||
cause: NonNullable<typeof recovery>["cause"],
|
||||
): Promise<Extract<InstallPluginResult, { ok: false }> | null> => {
|
||||
try {
|
||||
const quarantine = await quarantineManagedNpmProjectRebuildArtifacts({ npmRoot });
|
||||
recovery = { cause, quarantine };
|
||||
} catch (error) {
|
||||
return await rollbackFailedManagedNpmInstall({
|
||||
ok: false,
|
||||
error: `${cause.error}, but OpenClaw could not quarantine ${npmRoot} for rebuild: ${String(error)}`,
|
||||
});
|
||||
}
|
||||
logger.warn?.(
|
||||
`${cause.error}; quarantined ${formatManagedNpmProjectQuarantineArtifacts(recovery.quarantine.movedArtifactNames)} at ${recovery.quarantine.quarantineDir} and rebuilding once before retrying.`,
|
||||
);
|
||||
return null;
|
||||
};
|
||||
const syncManagedPeerDependenciesForInstall = async (options?: {
|
||||
omitUnsupportedManagedOverrides?: boolean;
|
||||
@@ -1502,13 +1493,18 @@ async function installPluginFromManagedNpmRoot(
|
||||
};
|
||||
}
|
||||
};
|
||||
let omitUnsupportedManagedOverrides = false;
|
||||
const preInstallRootPackageNames = await listManagedNpmRootPackageNames(npmRoot);
|
||||
await upsertManagedNpmRootDependency({
|
||||
npmRoot,
|
||||
packageName: params.packageName,
|
||||
dependencySpec: prepared.dependencySpec,
|
||||
managedOverrides,
|
||||
omitUnsupportedManagedOverrides,
|
||||
});
|
||||
const initialPeerSync = await syncManagedPeerDependenciesForInstall({
|
||||
omitUnsupportedManagedOverrides,
|
||||
});
|
||||
const initialPeerSync = await syncManagedPeerDependenciesForInstall();
|
||||
if (!initialPeerSync.ok) {
|
||||
return await rollbackFailedManagedNpmInstall({ ok: false, error: initialPeerSync.error });
|
||||
}
|
||||
@@ -1534,7 +1530,6 @@ async function installPluginFromManagedNpmRoot(
|
||||
}),
|
||||
};
|
||||
let install = await runCommandWithTimeout(npmInstallArgs, npmInstallOptions);
|
||||
let omitUnsupportedManagedOverrides = false;
|
||||
if (install.code !== 0 && isNpmAliasOverrideComparatorError(install)) {
|
||||
logger.warn?.(
|
||||
"npm rejected managed npm alias overrides; retrying plugin install without alias overrides for this npm version.",
|
||||
@@ -1558,42 +1553,24 @@ async function installPluginFromManagedNpmRoot(
|
||||
}
|
||||
install = await runCommandWithTimeout(npmInstallArgs, npmInstallOptions);
|
||||
}
|
||||
if (install.code !== 0 && isManagedNpmProjectCorruptionInstallFailure(install)) {
|
||||
if (!recovery && install.code !== 0 && isManagedNpmProjectCorruptionInstallFailure(install)) {
|
||||
const originalError = formatNpmCommandFailureOutput(install);
|
||||
let quarantine: ManagedNpmProjectQuarantine;
|
||||
try {
|
||||
quarantine = await quarantineManagedNpmProjectRebuildArtifacts({ npmRoot });
|
||||
} catch (error) {
|
||||
return await rollbackFailedManagedNpmInstall({
|
||||
ok: false,
|
||||
error: `npm install failed with a managed npm project corruption signature, but OpenClaw could not quarantine ${npmRoot} for rebuild: ${String(error)}. Original npm error: ${originalError}`,
|
||||
});
|
||||
}
|
||||
logger.warn?.(
|
||||
`npm reported a managed npm project corruption signature; quarantined ${formatManagedNpmProjectQuarantineArtifacts(quarantine.movedArtifactNames)} at ${quarantine.quarantineDir} and rebuilding once before retrying.`,
|
||||
);
|
||||
preInstallRootPackageNames = await listManagedNpmRootPackageNames(npmRoot);
|
||||
const recoveryPeerSync = await syncManagedPeerDependenciesForInstall({
|
||||
omitUnsupportedManagedOverrides,
|
||||
const recoveryFailure = await quarantineForRecovery({
|
||||
kind: "npm-corruption",
|
||||
error: `npm install failed with a managed npm project corruption signature. Original npm error: ${originalError}`,
|
||||
});
|
||||
if (!recoveryPeerSync.ok) {
|
||||
return await rollbackFailedManagedNpmInstallAfterQuarantine({
|
||||
ok: false,
|
||||
error: `managed npm project recovery failed after quarantining ${formatManagedNpmProjectQuarantineArtifacts(quarantine.movedArtifactNames)} at ${quarantine.quarantineDir}: ${recoveryPeerSync.error}. Original npm error: ${originalError}`,
|
||||
});
|
||||
}
|
||||
install = await runCommandWithTimeout(npmInstallArgs, npmInstallOptions);
|
||||
if (install.code !== 0) {
|
||||
return await rollbackFailedManagedNpmInstallAfterQuarantine({
|
||||
ok: false,
|
||||
error: `npm install failed after managed npm project recovery (quarantine: ${quarantine.quarantineDir}): ${formatNpmCommandFailureOutput(install)}. Original npm error: ${originalError}`,
|
||||
});
|
||||
if (recoveryFailure) {
|
||||
return recoveryFailure;
|
||||
}
|
||||
return await runManagedNpmInstall(prepared);
|
||||
}
|
||||
if (install.code !== 0) {
|
||||
const error = recovery
|
||||
? `npm install failed after managed npm project recovery (quarantine: ${recovery.quarantine.quarantineDir}): ${formatNpmCommandFailureOutput(install)}. Original ${recovery.cause.kind === "npm-corruption" ? "npm" : "verification"} error: ${recovery.cause.error}`
|
||||
: `npm install failed: ${formatNpmCommandFailureOutput(install)}`;
|
||||
return await rollbackFailedManagedNpmInstall({
|
||||
ok: false,
|
||||
error: `npm install failed: ${formatNpmCommandFailureOutput(install)}`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
let settledManagedPeerDependencies = false;
|
||||
@@ -1760,15 +1737,31 @@ async function installPluginFromManagedNpmRoot(
|
||||
error: `Failed to verify npm install metadata for ${params.packageName}: ${String(error)}`,
|
||||
});
|
||||
}
|
||||
const resolutionMismatch = resolveInstalledNpmResolutionMismatch({
|
||||
const resolutionVerification = verifyInstalledNpmResolution({
|
||||
packageName: params.packageName,
|
||||
expected: params.npmResolution,
|
||||
installed: installedDependency,
|
||||
});
|
||||
if (resolutionMismatch) {
|
||||
if (resolutionVerification.kind === "conflict") {
|
||||
return await rollbackFailedManagedNpmInstall({
|
||||
ok: false,
|
||||
error: resolutionMismatch,
|
||||
error: resolutionVerification.error,
|
||||
});
|
||||
}
|
||||
if (resolutionVerification.kind === "incomplete") {
|
||||
if (!recovery) {
|
||||
const recoveryFailure = await quarantineForRecovery({
|
||||
kind: "incomplete-metadata",
|
||||
error: resolutionVerification.error,
|
||||
});
|
||||
if (recoveryFailure) {
|
||||
return recoveryFailure;
|
||||
}
|
||||
return await runManagedNpmInstall(prepared);
|
||||
}
|
||||
return await rollbackFailedManagedNpmInstall({
|
||||
ok: false,
|
||||
error: `npm install metadata remained incomplete after managed npm project recovery (quarantine: ${recovery.quarantine.quarantineDir}): ${resolutionVerification.error}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user