fix(doctor): scope bundled runtime deps to active plugins

This commit is contained in:
Vincent Koc
2026-04-26 18:17:39 -07:00
parent efec8a4a84
commit eed7b13b62
7 changed files with 366 additions and 13 deletions

View File

@@ -71,7 +71,7 @@ describe("plugins cli list", () => {
await runPluginsCommand(["plugins", "doctor"]);
expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith();
expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith({ effectiveOnly: true });
expect(runtimeLogs).toContain("No plugin issues detected.");
});

View File

@@ -836,7 +836,7 @@ export function registerPluginsCli(program: Command) {
buildPluginDiagnosticsReport,
formatPluginCompatibilityNotice,
} = await import("../plugins/status.js");
const report = buildPluginDiagnosticsReport();
const report = buildPluginDiagnosticsReport({ effectiveOnly: true });
const errors = report.plugins.filter((p) => p.status === "error");
const diags = report.diagnostics.filter((d) => d.level === "error");
const compatibility = buildPluginCompatibilityNotices({ report });

View File

@@ -18,12 +18,21 @@ function writeJson(filePath: string, value: unknown) {
}
function writeBundledChannelPlugin(root: string, id: string, dependencies: Record<string, string>) {
writeBundledChannelOwnerPlugin(root, id, [id], dependencies);
}
function writeBundledChannelOwnerPlugin(
root: string,
id: string,
channels: string[],
dependencies: Record<string, string>,
) {
writeJson(path.join(root, "dist", "extensions", id, "package.json"), {
dependencies,
});
writeJson(path.join(root, "dist", "extensions", id, "openclaw.plugin.json"), {
id,
channels: [id],
channels,
configSchema: { type: "object" },
});
}
@@ -259,16 +268,16 @@ describe("doctor bundled plugin runtime deps", () => {
expect(result.conflicts).toEqual([]);
});
it("reports default-enabled bundled plugin deps", () => {
it("reports default-enabled gateway startup sidecar deps", () => {
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", "openai", "package.json"), {
writeJson(path.join(root, "dist", "extensions", "browser", "package.json"), {
dependencies: {
"openai-only": "1.0.0",
"browser-only": "1.0.0",
},
});
writeJson(path.join(root, "dist", "extensions", "openai", "openclaw.plugin.json"), {
id: "openai",
writeJson(path.join(root, "dist", "extensions", "browser", "openclaw.plugin.json"), {
id: "browser",
enabledByDefault: true,
configSchema: { type: "object" },
});
@@ -281,7 +290,39 @@ describe("doctor bundled plugin runtime deps", () => {
});
expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([
"openai-only@1.0.0",
"browser-only@1.0.0",
]);
expect(result.conflicts).toEqual([]);
});
it("reports explicitly enabled provider deps", () => {
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", "bedrock", "package.json"), {
dependencies: {
"bedrock-only": "1.0.0",
},
});
writeJson(path.join(root, "dist", "extensions", "bedrock", "openclaw.plugin.json"), {
id: "bedrock",
enabledByDefault: true,
providers: ["bedrock"],
configSchema: { type: "object" },
});
const result = scanBundledPluginRuntimeDeps({
packageRoot: root,
config: {
plugins: {
enabled: true,
allow: ["bedrock"],
entries: { bedrock: { enabled: true } },
},
},
});
expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([
"bedrock-only@1.0.0",
]);
expect(result.conflicts).toEqual([]);
});
@@ -352,6 +393,78 @@ describe("doctor bundled plugin runtime deps", () => {
expect(result.conflicts).toEqual([]);
});
it("does not repair inactive default-enabled provider deps", async () => {
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", "bedrock", "package.json"), {
dependencies: {
"bedrock-only": "1.0.0",
},
});
writeJson(path.join(root, "dist", "extensions", "bedrock", "openclaw.plugin.json"), {
id: "bedrock",
enabledByDefault: true,
providers: ["bedrock"],
configSchema: { type: "object" },
});
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
plugins: { enabled: true },
},
installDeps: (params) => {
installed.push(params);
},
});
expect(installed).toEqual([]);
});
it("repairs explicitly enabled provider deps", async () => {
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", "bedrock", "package.json"), {
dependencies: {
"bedrock-only": "1.0.0",
},
});
writeJson(path.join(root, "dist", "extensions", "bedrock", "openclaw.plugin.json"), {
id: "bedrock",
enabledByDefault: true,
providers: ["bedrock"],
configSchema: { type: "object" },
});
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
plugins: {
enabled: true,
allow: ["bedrock"],
entries: { bedrock: { enabled: true } },
},
},
installDeps: (params) => {
installed.push(params);
},
});
expect(installed).toEqual([
{
installRoot: resolveBundledRuntimeDependencyPackageInstallRoot(root),
missingSpecs: ["bedrock-only@1.0.0"],
installSpecs: ["bedrock-only@1.0.0"],
},
]);
});
it("repairs missing deps during non-interactive doctor", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
writeJson(path.join(root, "package.json"), { name: "openclaw" });
@@ -383,6 +496,35 @@ describe("doctor bundled plugin runtime deps", () => {
expect(readRetainedRuntimeDepsManifest(installRoot)).toEqual(["grammy@1.37.0"]);
});
it("repairs deps for configured channel owner plugins", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
writeJson(path.join(root, "package.json"), { name: "openclaw" });
writeBundledChannelOwnerPlugin(root, "chat-bridge", ["telegram"], { grammy: "1.37.0" });
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
plugins: { enabled: true },
channels: { telegram: { enabled: true } },
},
installDeps: (params) => {
installed.push(params);
},
});
const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root);
expect(installed).toEqual([
{
installRoot,
missingSpecs: ["grammy@1.37.0"],
installSpecs: ["grammy@1.37.0"],
},
]);
});
it("throws when bundled runtime dependency repair fails", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
const errors: string[] = [];

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
@@ -7,6 +8,7 @@ import {
scanBundledPluginRuntimeDeps,
type BundledRuntimeDepsInstallParams,
} from "../plugins/bundled-runtime-deps.js";
import { resolveEffectivePluginIds } from "../plugins/effective-plugin-ids.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
@@ -31,11 +33,23 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
return;
}
const env = params.env ?? process.env;
const bundledPluginsDir = path.join(packageRoot, "dist", "extensions");
const effectivePluginIds = params.config
? resolveEffectivePluginIds({
config: params.config,
env: {
...env,
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir,
},
})
: undefined;
const { deps, missing, conflicts } = scanBundledPluginRuntimeDeps({
packageRoot,
config: params.config,
pluginIds: effectivePluginIds,
includeConfiguredChannels: params.includeConfiguredChannels,
env: params.env ?? process.env,
env,
});
if (conflicts.length > 0) {
const conflictLines = conflicts.flatMap((conflict) =>

View File

@@ -930,9 +930,9 @@ function isBundledPluginConfiguredForRuntimeDeps(params: {
if (entry?.enabled === false) {
return false;
}
const manifest = readBundledPluginRuntimeDepsManifest(params.pluginDir, params.manifestCache);
let hasExplicitChannelDisable = false;
let hasConfiguredChannel = false;
const manifest = readBundledPluginRuntimeDepsManifest(params.pluginDir, params.manifestCache);
for (const channelId of manifest.channels) {
const normalizedChannelId = normalizeOptionalLowercaseString(channelId);
if (!normalizedChannelId) {
@@ -990,12 +990,26 @@ function shouldIncludeBundledPluginRuntimeDeps(params: {
includeConfiguredChannels?: boolean;
manifestCache?: BundledPluginRuntimeDepsManifestCache;
}): boolean {
if (params.pluginIds && !params.pluginIds.has(params.pluginId)) {
return false;
const scopedToPluginIds = Boolean(params.pluginIds);
if (params.pluginIds) {
if (!params.pluginIds.has(params.pluginId)) {
return false;
}
if (!params.config) {
return true;
}
}
if (!params.config) {
return true;
}
if (scopedToPluginIds) {
const plugins = normalizePluginsConfig(params.config.plugins);
if (!plugins.enabled || plugins.deny.includes(params.pluginId)) {
return false;
}
const entry = plugins.entries[params.pluginId];
return entry?.enabled !== false;
}
return isBundledPluginConfiguredForRuntimeDeps({
config: params.config,
pluginId: params.pluginId,

View File

@@ -0,0 +1,171 @@
import fs from "node:fs";
import path from "node:path";
import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import {
listExplicitConfiguredChannelIdsForConfig,
resolveConfiguredChannelPluginIds,
resolveGatewayStartupPluginIds,
} from "./channel-plugin-ids.js";
import { normalizePluginsConfig } from "./config-state.js";
import { loadPluginManifest } from "./manifest.js";
function listExplicitlyDisabledChannelIds(config: OpenClawConfig): Set<string> {
const channels = config.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return new Set();
}
return new Set(
Object.entries(channels)
.filter(([, value]) => {
return (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
(value as { enabled?: unknown }).enabled === false
);
})
.map(([channelId]) => normalizeOptionalLowercaseString(channelId))
.filter((channelId): channelId is string => Boolean(channelId)),
);
}
function collectConfiguredChannelIds(
config: OpenClawConfig,
activationSourceConfig: OpenClawConfig,
env: NodeJS.ProcessEnv,
): string[] {
const disabled = new Set([
...listExplicitlyDisabledChannelIds(config),
...listExplicitlyDisabledChannelIds(activationSourceConfig),
]);
const ids = new Set([
...listPotentialConfiguredChannelIds(config, env, { includePersistedAuthState: false }),
...listExplicitConfiguredChannelIdsForConfig(activationSourceConfig),
]);
return [...ids]
.map((channelId) => normalizeOptionalLowercaseString(channelId))
.filter((channelId): channelId is string => {
if (!channelId) {
return false;
}
return !disabled.has(channelId);
})
.toSorted((left, right) => left.localeCompare(right));
}
function collectBundledChannelOwnerPluginIds(params: {
channelIds: readonly string[];
env: NodeJS.ProcessEnv;
}): string[] {
const channelIds = new Set(
params.channelIds
.map((channelId) => normalizeOptionalLowercaseString(channelId))
.filter((channelId): channelId is string => Boolean(channelId)),
);
if (channelIds.size === 0) {
return [];
}
const bundledDir = resolveBundledPluginsDir(params.env);
if (!bundledDir) {
return [];
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(bundledDir, { withFileTypes: true });
} catch {
return [];
}
const pluginIds = new Set<string>();
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const pluginDir = path.join(bundledDir, entry.name);
const manifest = loadPluginManifest(pluginDir, false);
if (!manifest.ok) {
continue;
}
if (
(manifest.manifest.channels ?? []).some((channelId) =>
channelIds.has(normalizeOptionalLowercaseString(channelId) ?? ""),
)
) {
const pluginId = normalizeOptionalLowercaseString(manifest.manifest.id);
if (pluginId) {
pluginIds.add(pluginId);
}
}
}
return [...pluginIds].toSorted((left, right) => left.localeCompare(right));
}
function collectExplicitEffectivePluginIds(config: OpenClawConfig): string[] {
const plugins = normalizePluginsConfig(config.plugins);
if (!plugins.enabled) {
return [];
}
const ids = new Set(plugins.allow);
for (const [pluginId, entry] of Object.entries(plugins.entries)) {
if (
entry?.enabled === true &&
(plugins.allow.length === 0 || plugins.allow.includes(pluginId))
) {
ids.add(pluginId);
}
}
for (const pluginId of plugins.deny) {
ids.delete(pluginId);
}
for (const [pluginId, entry] of Object.entries(plugins.entries)) {
if (entry?.enabled === false) {
ids.delete(pluginId);
}
}
return [...ids].toSorted((left, right) => left.localeCompare(right));
}
export function resolveEffectivePluginIds(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
}): string[] {
const autoEnabled = applyPluginAutoEnable({
config: params.config,
env: params.env,
});
const effectiveConfig = autoEnabled.config;
const ids = new Set(collectExplicitEffectivePluginIds(effectiveConfig));
const configuredChannelIds = collectConfiguredChannelIds(
effectiveConfig,
params.config,
params.env,
);
for (const pluginId of resolveConfiguredChannelPluginIds({
config: effectiveConfig,
activationSourceConfig: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})) {
ids.add(pluginId);
}
for (const pluginId of collectBundledChannelOwnerPluginIds({
channelIds: configuredChannelIds,
env: params.env,
})) {
ids.add(pluginId);
}
for (const pluginId of resolveGatewayStartupPluginIds({
config: effectiveConfig,
activationSourceConfig: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})) {
ids.add(pluginId);
}
return [...ids].toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -12,6 +12,7 @@ import {
} from "./bundled-compat.js";
import type { PluginCompatCode } from "./compat/registry.js";
import { normalizePluginsConfig } from "./config-state.js";
import { resolveEffectivePluginIds } from "./effective-plugin-ids.js";
import {
buildPluginShapeSummary,
type PluginCapabilityEntry,
@@ -149,6 +150,7 @@ function resolveReportedPluginVersion(
type PluginReportParams = {
config?: OpenClawConfig;
effectiveOnly?: boolean;
workspaceDir?: string;
/** Use an explicit env when plugin roots should resolve independently from process.env. */
env?: NodeJS.ProcessEnv;
@@ -273,6 +275,14 @@ function buildPluginReport(
config: effectiveConfig,
pluginIds: bundledProviderIds,
});
const onlyPluginIds =
params?.effectiveOnly === true
? resolveEffectivePluginIds({
config: rawConfig,
workspaceDir,
env: params?.env ?? process.env,
})
: undefined;
const registry = loadModules
? loadOpenClawPlugins(
@@ -284,6 +294,7 @@ function buildPluginReport(
loadModules,
activate: false,
cache: false,
onlyPluginIds,
}),
)
: loadPluginMetadataRegistrySnapshot({
@@ -293,6 +304,7 @@ function buildPluginReport(
env: params?.env,
logger: params?.logger,
loadModules: false,
onlyPluginIds,
});
const importedPluginIds = new Set([
...(loadModules