diff --git a/src/plugins/install-npm-resolution.ts b/src/plugins/install-npm-resolution.ts new file mode 100644 index 000000000000..7dbf531597c1 --- /dev/null +++ b/src/plugins/install-npm-resolution.ts @@ -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" }; +} diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index be7e47d2f551..3dda02117276 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -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; + }; + 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 }; + 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; + 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; + 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); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index fbc00342d11e..63b371d2067f 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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> { 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> + | 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, ): Promise> => { @@ -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, - ): Promise> => { - 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["cause"], + ): Promise | 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}`, }); }