mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:00:45 +00:00
fix(doctor): scope bundled runtime deps to active plugins
This commit is contained in:
@@ -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.");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
171
src/plugins/effective-plugin-ids.ts
Normal file
171
src/plugins/effective-plugin-ids.ts
Normal 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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user