fix: harden bundled plugin runtime deps

This commit is contained in:
Peter Steinberger
2026-04-01 08:54:16 +01:00
parent edfac5f2df
commit 95182d51cc
6 changed files with 390 additions and 22 deletions

View File

@@ -0,0 +1,67 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { scanBundledPluginRuntimeDeps } from "./doctor-bundled-plugin-runtime-deps.js";
function writeJson(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
describe("doctor bundled plugin runtime deps", () => {
it("skips source checkouts", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
fs.mkdirSync(path.join(root, ".git"));
fs.mkdirSync(path.join(root, "src"));
fs.mkdirSync(path.join(root, "extensions"));
writeJson(path.join(root, "dist", "extensions", "discord", "package.json"), {
dependencies: {
"dep-one": "1.0.0",
},
});
const result = scanBundledPluginRuntimeDeps({ packageRoot: root });
expect(result.missing).toEqual([]);
expect(result.conflicts).toEqual([]);
});
it("reports missing deps and conflicts", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
writeJson(path.join(root, "package.json"), { name: "openclaw" });
writeJson(path.join(root, "dist", "extensions", "alpha", "package.json"), {
dependencies: {
"dep-one": "1.0.0",
"@scope/dep-two": "2.0.0",
},
optionalDependencies: {
"dep-opt": "3.0.0",
},
});
writeJson(path.join(root, "dist", "extensions", "beta", "package.json"), {
dependencies: {
"dep-one": "1.0.0",
"dep-conflict": "1.0.0",
},
});
writeJson(path.join(root, "dist", "extensions", "gamma", "package.json"), {
dependencies: {
"dep-conflict": "2.0.0",
},
});
writeJson(path.join(root, "node_modules", "dep-one", "package.json"), {
name: "dep-one",
version: "1.0.0",
});
const result = scanBundledPluginRuntimeDeps({ packageRoot: root });
const missing = result.missing.map((dep) => `${dep.name}@${dep.version}`);
expect(missing).toEqual(["@scope/dep-two@2.0.0", "dep-opt@3.0.0"]);
expect(result.conflicts).toHaveLength(1);
expect(result.conflicts[0]?.name).toBe("dep-conflict");
expect(result.conflicts[0]?.versions).toEqual(["1.0.0", "2.0.0"]);
});
});

View File

@@ -0,0 +1,243 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
type RuntimeDepEntry = {
name: string;
version: string;
pluginIds: string[];
};
type RuntimeDepConflict = {
name: string;
versions: string[];
pluginIdsByVersion: Map<string, string[]>;
};
function isSourceCheckoutRoot(packageRoot: string): boolean {
return (
fs.existsSync(path.join(packageRoot, ".git")) &&
fs.existsSync(path.join(packageRoot, "src")) &&
fs.existsSync(path.join(packageRoot, "extensions"))
);
}
function dependencySentinelPath(depName: string): string {
return path.join("node_modules", ...depName.split("/"), "package.json");
}
function collectRuntimeDeps(packageJson: Record<string, unknown>): Record<string, unknown> {
return {
...(packageJson.dependencies as Record<string, unknown> | undefined),
...(packageJson.optionalDependencies as Record<string, unknown> | undefined),
};
}
function collectBundledPluginRuntimeDeps(params: { extensionsDir: string }): {
deps: RuntimeDepEntry[];
conflicts: RuntimeDepConflict[];
} {
const versionMap = new Map<string, Map<string, Set<string>>>();
for (const entry of fs.readdirSync(params.extensionsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const pluginId = entry.name;
const packageJsonPath = path.join(params.extensionsDir, pluginId, "package.json");
if (!fs.existsSync(packageJsonPath)) {
continue;
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as Record<
string,
unknown
>;
for (const [name, rawVersion] of Object.entries(collectRuntimeDeps(packageJson))) {
if (typeof rawVersion !== "string" || rawVersion.trim() === "") {
continue;
}
const version = rawVersion.trim();
const byVersion = versionMap.get(name) ?? new Map<string, Set<string>>();
const pluginIds = byVersion.get(version) ?? new Set<string>();
pluginIds.add(pluginId);
byVersion.set(version, pluginIds);
versionMap.set(name, byVersion);
}
} catch {
// Ignore malformed plugin manifests; doctor will surface those separately.
}
}
const deps: RuntimeDepEntry[] = [];
const conflicts: RuntimeDepConflict[] = [];
for (const [name, byVersion] of versionMap.entries()) {
if (byVersion.size === 1) {
const [version, pluginIds] = [...byVersion.entries()][0] ?? [];
if (version) {
deps.push({
name,
version,
pluginIds: [...pluginIds].toSorted((a, b) => a.localeCompare(b)),
});
}
continue;
}
const versions = [...byVersion.keys()].toSorted((a, b) => a.localeCompare(b));
const pluginIdsByVersion = new Map<string, string[]>();
for (const [version, pluginIds] of byVersion.entries()) {
pluginIdsByVersion.set(
version,
[...pluginIds].toSorted((a, b) => a.localeCompare(b)),
);
}
conflicts.push({
name,
versions,
pluginIdsByVersion,
});
}
return {
deps: deps.toSorted((a, b) => a.name.localeCompare(b.name)),
conflicts: conflicts.toSorted((a, b) => a.name.localeCompare(b.name)),
};
}
export function scanBundledPluginRuntimeDeps(params: { packageRoot: string }): {
missing: RuntimeDepEntry[];
conflicts: RuntimeDepConflict[];
} {
if (isSourceCheckoutRoot(params.packageRoot)) {
return { missing: [], conflicts: [] };
}
const extensionsDir = path.join(params.packageRoot, "dist", "extensions");
if (!fs.existsSync(extensionsDir)) {
return { missing: [], conflicts: [] };
}
const { deps, conflicts } = collectBundledPluginRuntimeDeps({ extensionsDir });
const missing = deps.filter(
(dep) => !fs.existsSync(path.join(params.packageRoot, dependencySentinelPath(dep.name))),
);
return { missing, conflicts };
}
function createNestedNpmInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const nextEnv = { ...env };
delete nextEnv.npm_config_global;
delete nextEnv.npm_config_location;
delete nextEnv.npm_config_prefix;
return nextEnv;
}
function installBundledRuntimeDeps(params: {
packageRoot: string;
missingSpecs: string[];
env: NodeJS.ProcessEnv;
}) {
const result = spawnSync(
"npm",
[
"install",
"--omit=dev",
"--no-save",
"--package-lock=false",
"--ignore-scripts",
"--legacy-peer-deps",
...params.missingSpecs,
],
{
cwd: params.packageRoot,
encoding: "utf8",
env: createNestedNpmInstallEnv(params.env),
stdio: "pipe",
shell: false,
},
);
if (result.status !== 0) {
const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
throw new Error(output || "npm install failed");
}
}
export async function maybeRepairBundledPluginRuntimeDeps(params: {
runtime: RuntimeEnv;
prompter: DoctorPrompter;
env?: NodeJS.ProcessEnv;
packageRoot?: string | null;
installDeps?: (params: { packageRoot: string; missingSpecs: string[] }) => void;
}): Promise<void> {
const packageRoot =
params.packageRoot ??
resolveOpenClawPackageRootSync({
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
if (!packageRoot) {
return;
}
const { missing, conflicts } = scanBundledPluginRuntimeDeps({ packageRoot });
if (conflicts.length > 0) {
const conflictLines = conflicts.flatMap((conflict) => [
`- ${conflict.name}: ${conflict.versions.join(", ")}`,
...conflict.versions.flatMap((version) => {
const pluginIds = conflict.pluginIdsByVersion.get(version) ?? [];
return pluginIds.length > 0 ? [` - ${version}: ${pluginIds.join(", ")}`] : [];
}),
]);
note(
[
"Bundled plugin runtime deps use conflicting versions.",
...conflictLines,
`Update bundled plugins and rerun ${formatCliCommand("openclaw doctor")}.`,
].join("\n"),
"Bundled plugins",
);
}
if (missing.length === 0) {
return;
}
const missingSpecs = missing.map((dep) => `${dep.name}@${dep.version}`);
note(
[
"Bundled plugin runtime deps are missing.",
...missing.map((dep) => `- ${dep.name}@${dep.version} (used by ${dep.pluginIds.join(", ")})`),
`Fix: run ${formatCliCommand("openclaw doctor --fix")} to install them.`,
].join("\n"),
"Bundled plugins",
);
const shouldRepair =
params.prompter.shouldRepair ||
(await params.prompter.confirmAutoFix({
message: "Install missing bundled plugin runtime deps now?",
initialValue: true,
}));
if (!shouldRepair) {
return;
}
try {
const install =
params.installDeps ??
((installParams) =>
installBundledRuntimeDeps({
packageRoot: installParams.packageRoot,
missingSpecs: installParams.missingSpecs,
env: params.env ?? process.env,
}));
install({ packageRoot, missingSpecs });
note(`Installed bundled plugin deps: ${missingSpecs.join(", ")}`, "Bundled plugins");
} catch (error) {
params.runtime.error(`Failed to install bundled plugin runtime deps: ${String(error)}`);
}
}

View File

@@ -15,6 +15,7 @@ import {
} from "../commands/doctor-auth.js";
import { noteBootstrapFileSize } from "../commands/doctor-bootstrap-size.js";
import { noteChromeMcpBrowserReadiness } from "../commands/doctor-browser.js";
import { maybeRepairBundledPluginRuntimeDeps } from "../commands/doctor-bundled-plugin-runtime-deps.js";
import { doctorShellCompletion } from "../commands/doctor-completion.js";
import { maybeRepairLegacyCronStore } from "../commands/doctor-cron.js";
import { maybeRepairGatewayDaemon } from "../commands/doctor-gateway-daemon-flow.js";
@@ -243,6 +244,13 @@ async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Prom
});
}
async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
await maybeRepairBundledPluginRuntimeDeps({
runtime: ctx.runtime,
prompter: ctx.prompter,
});
}
async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath);
}
@@ -495,6 +503,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
label: "Legacy plugin manifests",
run: runLegacyPluginManifestHealth,
}),
createDoctorHealthContribution({
id: "doctor:bundled-plugin-runtime-deps",
label: "Bundled plugin runtime deps",
run: runBundledPluginRuntimeDepsHealth,
}),
createDoctorHealthContribution({
id: "doctor:state-integrity",
label: "State integrity",