From 47d42606ac5ae765944c1ecaaeced909ad6d7194 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 17:43:50 +0100 Subject: [PATCH] fix: repair bundled plugin runtime deps on startup --- CHANGELOG.md | 1 + docs/gateway/doctor.md | 12 +- docs/plugins/sdk-setup.md | 6 + docs/tools/plugin.md | 7 + scripts/build-all.mjs | 8 - ...doctor-bundled-plugin-runtime-deps.test.ts | 95 ++++- .../doctor-bundled-plugin-runtime-deps.ts | 178 +-------- src/flows/doctor-health-contributions.ts | 1 + src/plugins/bundled-runtime-deps.ts | 363 ++++++++++++++++++ src/plugins/loader.test.ts | 260 +++++++++++++ src/plugins/loader.ts | 41 +- test/scripts/build-all.test.ts | 6 + 12 files changed, 798 insertions(+), 180 deletions(-) create mode 100644 src/plugins/bundled-runtime-deps.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 399d70b862b..a54b896887c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Cron/delivery: treat explicit `delivery.mode: "none"` runs as not requested even if the runner reports `delivered: false`, so no-delivery cron jobs no longer persist false delivery failures or errors. (#69285) Thanks @matsuri1987. +- Plugins/install: repair active and default-enabled bundled plugin runtime dependencies before import in packaged installs, so bundled Discord, WhatsApp, Slack, Telegram, and provider plugins work without putting their dependency trees in core. ## 2026.4.20 diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index ef634739fdc..eb714d157d8 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -380,10 +380,14 @@ switch to legacy names if the current image is missing. ### 7b) Bundled plugin runtime deps -Doctor verifies that bundled plugin runtime dependencies (for example the -Discord plugin runtime packages) are present in the OpenClaw install root. -If any are missing, doctor reports the packages and installs them in -`openclaw doctor --fix` / `openclaw doctor --repair` mode. +Doctor verifies runtime dependencies only for bundled plugins that are active in +the current config or enabled by their bundled manifest default, for example +`plugins.entries.discord.enabled: true`, legacy +`channels.discord.enabled: true`, or a default-enabled bundled provider. If any +are missing, doctor reports the packages and installs them in +`openclaw doctor --fix` / `openclaw doctor --repair` mode. External plugins still +use `openclaw plugins install` / `openclaw plugins update`; doctor does not +install dependencies for arbitrary plugin paths. ### 8) Gateway service migrations and cleanup hints diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 81ba55de397..2918543189d 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -529,6 +529,12 @@ openclaw plugins install trees pure JS/TS and avoid packages that require `postinstall` builds. +Bundled OpenClaw-owned plugins are the only startup repair exception: when a +packaged install sees one enabled by plugin config, legacy channel config, or +its bundled default-enabled manifest, startup installs that plugin's missing +runtime dependencies before import. Third-party plugins should not rely on +startup installs; keep using the explicit plugin installer. + ## Related - [SDK Entry Points](/plugins/sdk-entrypoints) -- `definePluginEntry` and `defineChannelPluginEntry` diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index d47cb8505f5..e6a4ac7201d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -63,6 +63,13 @@ If config is invalid, install normally fails closed and points you at reinstall path for plugins that opt into `openclaw.install.allowInvalidConfigRecovery`. +Packaged OpenClaw installs do not eagerly install every bundled plugin's +runtime dependency tree. When a bundled OpenClaw-owned plugin is active from +plugin config, legacy channel config, or a default-enabled manifest, startup +repairs only that plugin's declared runtime dependencies before importing it. +External plugins and custom load paths must still be installed through +`openclaw plugins install`. + ## Plugin types OpenClaw recognizes two plugin formats: diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 43a272283e6..9a567ae812d 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -51,14 +51,6 @@ export const BUILD_ALL_STEPS = [ label: "write-plugin-sdk-entry-dts", kind: "node", args: ["--import", "tsx", "scripts/write-plugin-sdk-entry-dts.ts"], - cache: { - inputs: [ - "scripts/write-plugin-sdk-entry-dts.ts", - "scripts/lib/plugin-sdk-entrypoints.json", - "dist/plugin-sdk/src/plugin-sdk", - ], - outputs: ["dist/plugin-sdk", "packages/plugin-sdk/dist/src/plugin-sdk"], - }, }, { label: "check-plugin-sdk-exports", diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index 79a5a090a83..d7d906751c0 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -2,7 +2,7 @@ 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"; +import { scanBundledPluginRuntimeDeps } from "../plugins/bundled-runtime-deps.js"; function writeJson(filePath: string, value: unknown) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -64,4 +64,97 @@ describe("doctor bundled plugin runtime deps", () => { expect(result.conflicts[0]?.name).toBe("dep-conflict"); expect(result.conflicts[0]?.versions).toEqual(["1.0.0", "2.0.0"]); }); + + it("limits configured scans to enabled bundled channel plugins", () => { + 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", "discord", "package.json"), { + dependencies: { + "discord-only": "1.0.0", + }, + }); + writeJson(path.join(root, "dist", "extensions", "discord", "openclaw.plugin.json"), { + id: "discord", + channels: ["discord"], + configSchema: { type: "object" }, + }); + writeJson(path.join(root, "dist", "extensions", "whatsapp", "package.json"), { + dependencies: { + "whatsapp-only": "1.0.0", + }, + }); + writeJson(path.join(root, "dist", "extensions", "whatsapp", "openclaw.plugin.json"), { + id: "whatsapp", + channels: ["whatsapp"], + configSchema: { type: "object" }, + }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + config: { + plugins: { enabled: true }, + channels: { + discord: { enabled: true }, + }, + }, + }); + + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ + "discord-only@1.0.0", + ]); + expect(result.conflicts).toEqual([]); + }); + + it("does not report bundled channel deps when the channel is not enabled", () => { + 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", "discord", "package.json"), { + dependencies: { + "discord-only": "1.0.0", + }, + }); + writeJson(path.join(root, "dist", "extensions", "discord", "openclaw.plugin.json"), { + id: "discord", + channels: ["discord"], + configSchema: { type: "object" }, + }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + config: { + plugins: { enabled: true }, + }, + }); + + expect(result.missing).toEqual([]); + expect(result.conflicts).toEqual([]); + }); + + it("reports default-enabled bundled plugin 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"), { + dependencies: { + "openai-only": "1.0.0", + }, + }); + writeJson(path.join(root, "dist", "extensions", "openai", "openclaw.plugin.json"), { + id: "openai", + enabledByDefault: true, + configSchema: { type: "object" }, + }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + config: { + plugins: { enabled: true }, + }, + }); + + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ + "openai-only@1.0.0", + ]); + expect(result.conflicts).toEqual([]); + }); }); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index d99b4426fd5..cbb369ae7aa 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -1,176 +1,21 @@ -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -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"; +import { + installBundledRuntimeDeps, + scanBundledPluginRuntimeDeps, +} from "../plugins/bundled-runtime-deps.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; -}; - -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): Record { - return { - ...(packageJson.dependencies as Record | undefined), - ...(packageJson.optionalDependencies as Record | undefined), - }; -} - -function collectBundledPluginRuntimeDeps(params: { extensionsDir: string }): { - deps: RuntimeDepEntry[]; - conflicts: RuntimeDepConflict[]; -} { - const versionMap = new Map>>(); - - 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>(); - const pluginIds = byVersion.get(version) ?? new Set(); - 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(); - 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; + config?: OpenClawConfig; env?: NodeJS.ProcessEnv; packageRoot?: string | null; - installDeps?: (params: { packageRoot: string; missingSpecs: string[] }) => void; + installDeps?: (params: { installRoot: string; missingSpecs: string[] }) => void; }): Promise { const packageRoot = params.packageRoot ?? @@ -183,7 +28,10 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { return; } - const { missing, conflicts } = scanBundledPluginRuntimeDeps({ packageRoot }); + const { missing, conflicts } = scanBundledPluginRuntimeDeps({ + packageRoot, + config: params.config, + }); if (conflicts.length > 0) { const conflictLines = conflicts.flatMap((conflict) => [`- ${conflict.name}: ${conflict.versions.join(", ")}`].concat( @@ -232,11 +80,11 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { params.installDeps ?? ((installParams) => installBundledRuntimeDeps({ - packageRoot: installParams.packageRoot, + installRoot: installParams.installRoot, missingSpecs: installParams.missingSpecs, env: params.env ?? process.env, })); - install({ packageRoot, missingSpecs }); + install({ installRoot: 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)}`); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 7831e37c169..75413e76212 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -255,6 +255,7 @@ async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext): await maybeRepairBundledPluginRuntimeDeps({ runtime: ctx.runtime, prompter: ctx.prompter, + config: ctx.cfg, }); } diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts new file mode 100644 index 00000000000..d183e396530 --- /dev/null +++ b/src/plugins/bundled-runtime-deps.ts @@ -0,0 +1,363 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { normalizeChatChannelId } from "../channels/ids.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizePluginsConfig } from "./config-state.js"; + +export type RuntimeDepEntry = { + name: string; + version: string; + pluginIds: string[]; +}; + +export type RuntimeDepConflict = { + name: string; + versions: string[]; + pluginIdsByVersion: Map; +}; + +export type BundledRuntimeDepsInstallParams = { + installRoot: string; + missingSpecs: string[]; + installSpecs?: string[]; +}; + +type JsonObject = Record; + +function dependencySentinelPath(depName: string): string { + return path.join("node_modules", ...depName.split("/"), "package.json"); +} + +function readJsonObject(filePath: string): JsonObject | null { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as JsonObject; + } catch { + return null; + } +} + +function collectRuntimeDeps(packageJson: JsonObject): Record { + return { + ...(packageJson.dependencies as Record | undefined), + ...(packageJson.optionalDependencies as Record | undefined), + }; +} + +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 isSourceCheckoutBundledPluginRoot(pluginRoot: string): boolean { + const extensionsDir = path.dirname(pluginRoot); + if (path.basename(extensionsDir) !== "extensions") { + return false; + } + return isSourceCheckoutRoot(path.dirname(extensionsDir)); +} + +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 readBundledPluginChannels(pluginDir: string): string[] { + const manifest = readJsonObject(path.join(pluginDir, "openclaw.plugin.json")); + const channels = manifest?.channels; + if (!Array.isArray(channels)) { + return []; + } + return channels.filter((entry): entry is string => typeof entry === "string" && entry !== ""); +} + +function readBundledPluginEnabledByDefault(pluginDir: string): boolean { + return readJsonObject(path.join(pluginDir, "openclaw.plugin.json"))?.enabledByDefault === true; +} + +function isBundledPluginConfiguredForRuntimeDeps(params: { + config: OpenClawConfig; + pluginId: string; + pluginDir: string; +}): boolean { + const plugins = normalizePluginsConfig(params.config.plugins); + if (!plugins.enabled) { + return false; + } + if (plugins.deny.includes(params.pluginId)) { + return false; + } + const entry = plugins.entries[params.pluginId]; + if (entry?.enabled === false) { + return false; + } + if (entry?.enabled === true) { + return true; + } + for (const channelId of readBundledPluginChannels(params.pluginDir)) { + const normalizedChannelId = normalizeChatChannelId(channelId); + if (!normalizedChannelId) { + continue; + } + const channelConfig = (params.config.channels as Record | undefined)?.[ + normalizedChannelId + ]; + if ( + channelConfig && + typeof channelConfig === "object" && + !Array.isArray(channelConfig) && + (channelConfig as { enabled?: unknown }).enabled === true + ) { + return true; + } + } + return readBundledPluginEnabledByDefault(params.pluginDir); +} + +function shouldIncludeBundledPluginRuntimeDeps(params: { + config?: OpenClawConfig; + pluginIds?: ReadonlySet; + pluginId: string; + pluginDir: string; +}): boolean { + if (params.pluginIds && !params.pluginIds.has(params.pluginId)) { + return false; + } + if (!params.config) { + return true; + } + return isBundledPluginConfiguredForRuntimeDeps({ + config: params.config, + pluginId: params.pluginId, + pluginDir: params.pluginDir, + }); +} + +function collectBundledPluginRuntimeDeps(params: { + extensionsDir: string; + config?: OpenClawConfig; + pluginIds?: ReadonlySet; +}): { + deps: RuntimeDepEntry[]; + conflicts: RuntimeDepConflict[]; +} { + const versionMap = new Map>>(); + + for (const entry of fs.readdirSync(params.extensionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const pluginId = entry.name; + const pluginDir = path.join(params.extensionsDir, pluginId); + if ( + !shouldIncludeBundledPluginRuntimeDeps({ + config: params.config, + pluginIds: params.pluginIds, + pluginId, + pluginDir, + }) + ) { + continue; + } + const packageJson = readJsonObject(path.join(pluginDir, "package.json")); + if (!packageJson) { + continue; + } + 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>(); + const pluginIds = byVersion.get(version) ?? new Set(); + pluginIds.add(pluginId); + byVersion.set(version, pluginIds); + versionMap.set(name, byVersion); + } + } + + 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(); + 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)), + }; +} + +function normalizePluginIdSet( + pluginIds: readonly string[] | undefined, +): ReadonlySet | undefined { + if (!pluginIds) { + return undefined; + } + const normalized = pluginIds + .map((entry) => normalizeOptionalLowercaseString(entry)) + .filter((entry): entry is string => Boolean(entry)); + return new Set(normalized); +} + +export function scanBundledPluginRuntimeDeps(params: { + packageRoot: string; + config?: OpenClawConfig; + pluginIds?: readonly 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, + config: params.config, + pluginIds: normalizePluginIdSet(params.pluginIds), + }); + const missing = deps.filter( + (dep) => !fs.existsSync(path.join(params.packageRoot, dependencySentinelPath(dep.name))), + ); + return { missing, conflicts }; +} + +export function resolveBundledRuntimeDependencyInstallRoot(pluginRoot: string): string { + const extensionsDir = path.dirname(pluginRoot); + const buildDir = path.dirname(extensionsDir); + if ( + path.basename(extensionsDir) === "extensions" && + (path.basename(buildDir) === "dist" || path.basename(buildDir) === "dist-runtime") + ) { + return path.dirname(buildDir); + } + return extensionsDir; +} + +export function installBundledRuntimeDeps(params: { + installRoot: string; + missingSpecs: string[]; + env: NodeJS.ProcessEnv; +}): void { + const result = spawnSync( + "npm", + [ + "install", + "--omit=dev", + "--no-save", + "--package-lock=false", + "--ignore-scripts", + "--legacy-peer-deps", + ...params.missingSpecs, + ], + { + cwd: params.installRoot, + 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 function ensureBundledPluginRuntimeDeps(params: { + pluginId: string; + pluginRoot: string; + env: NodeJS.ProcessEnv; + config?: OpenClawConfig; + retainSpecs?: readonly string[]; + installDeps?: (params: BundledRuntimeDepsInstallParams) => void; +}): string[] { + if (isSourceCheckoutBundledPluginRoot(params.pluginRoot)) { + return []; + } + if ( + params.config && + !isBundledPluginConfiguredForRuntimeDeps({ + config: params.config, + pluginId: params.pluginId, + pluginDir: params.pluginRoot, + }) + ) { + return []; + } + const packageJson = readJsonObject(path.join(params.pluginRoot, "package.json")); + if (!packageJson) { + return []; + } + const deps = Object.entries(collectRuntimeDeps(packageJson)) + .map(([name, rawVersion]) => + typeof rawVersion === "string" && rawVersion.trim() !== "" + ? { name, version: rawVersion.trim() } + : null, + ) + .filter((entry): entry is { name: string; version: string } => Boolean(entry)); + if (deps.length === 0) { + return []; + } + + const installRoot = resolveBundledRuntimeDependencyInstallRoot(params.pluginRoot); + const missingSpecs = deps + .filter((dep) => !fs.existsSync(path.join(installRoot, dependencySentinelPath(dep.name)))) + .map((dep) => `${dep.name}@${dep.version}`) + .toSorted((left, right) => left.localeCompare(right)); + if (missingSpecs.length === 0) { + return []; + } + const installSpecs = [...new Set([...(params.retainSpecs ?? []), ...missingSpecs])].toSorted( + (left, right) => left.localeCompare(right), + ); + + const install = + params.installDeps ?? + ((installParams) => + installBundledRuntimeDeps({ + installRoot: installParams.installRoot, + missingSpecs: installParams.installSpecs ?? installParams.missingSpecs, + env: params.env, + })); + install({ installRoot, missingSpecs, installSpecs }); + return missingSpecs; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 15c8f966bad..73106fc0fe4 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -846,6 +846,266 @@ describe("loadOpenClawPlugins", () => { expect(bundled?.status).toBe("disabled"); }); + it("repairs enabled bundled plugin runtime deps before importing the plugin", () => { + const bundledDir = makeTempDir(); + const plugin = writePlugin({ + id: "discord", + dir: path.join(bundledDir, "discord"), + filename: "index.cjs", + body: `const dep = require("discord-runtime/package.json"); +module.exports = { + id: "discord", + register() { + if (dep.name !== "discord-runtime") { + throw new Error("missing runtime dep"); + } + }, +};`, + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + fs.writeFileSync( + path.join(plugin.dir, "package.json"), + JSON.stringify( + { + name: "@openclaw/discord", + version: "1.0.0", + dependencies: { + "discord-runtime": "1.0.0", + }, + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "discord", + channels: ["discord"], + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + const installedSpecs: string[] = []; + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + }, + channels: { + discord: { + enabled: true, + }, + }, + }, + bundledRuntimeDepsInstaller: ({ installRoot, missingSpecs }) => { + installedSpecs.push(...missingSpecs); + expect(fs.realpathSync(installRoot)).toBe(fs.realpathSync(bundledDir)); + fs.mkdirSync(path.join(installRoot, "node_modules", "discord-runtime"), { + recursive: true, + }); + fs.writeFileSync( + path.join(installRoot, "node_modules", "discord-runtime", "package.json"), + JSON.stringify({ name: "discord-runtime", version: "1.0.0" }), + "utf-8", + ); + }, + }); + + expect(installedSpecs).toEqual(["discord-runtime@1.0.0"]); + expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded"); + }); + + it("does not repair disabled bundled plugin runtime deps", () => { + const bundledDir = makeTempDir(); + const plugin = writePlugin({ + id: "discord", + dir: path.join(bundledDir, "discord"), + filename: "index.cjs", + body: `module.exports = { id: "discord", register() {} };`, + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + fs.writeFileSync( + path.join(plugin.dir, "package.json"), + JSON.stringify( + { + name: "@openclaw/discord", + version: "1.0.0", + dependencies: { + "discord-runtime": "1.0.0", + }, + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + }, + }, + bundledRuntimeDepsInstaller: () => { + throw new Error("disabled plugin deps should not install"); + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("disabled"); + }); + + it("repairs default-enabled bundled plugin runtime deps", () => { + const bundledDir = makeTempDir(); + const plugin = writePlugin({ + id: "openai", + dir: path.join(bundledDir, "openai"), + filename: "index.cjs", + body: `module.exports = { id: "openai", register() {} };`, + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + fs.writeFileSync( + path.join(plugin.dir, "package.json"), + JSON.stringify( + { + name: "@openclaw/openai", + version: "1.0.0", + dependencies: { + "openai-runtime": "1.0.0", + }, + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "openai", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + const installedSpecs: string[] = []; + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + }, + }, + bundledRuntimeDepsInstaller: ({ missingSpecs }) => { + installedSpecs.push(...missingSpecs); + }, + }); + + expect(installedSpecs).toEqual(["openai-runtime@1.0.0"]); + expect(registry.plugins.find((entry) => entry.id === "openai")?.status).toBe("loaded"); + }); + + it("retains earlier bundled runtime deps across sequential repairs", () => { + const bundledDir = makeTempDir(); + const alpha = writePlugin({ + id: "alpha", + dir: path.join(bundledDir, "alpha"), + filename: "index.cjs", + body: `module.exports = { id: "alpha", register() {} };`, + }); + const beta = writePlugin({ + id: "beta", + dir: path.join(bundledDir, "beta"), + filename: "index.cjs", + body: `module.exports = { id: "beta", register() {} };`, + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + for (const [plugin, depName] of [ + [alpha, "alpha-runtime"], + [beta, "beta-runtime"], + ] as const) { + fs.writeFileSync( + path.join(plugin.dir, "package.json"), + JSON.stringify( + { + name: `@openclaw/${plugin.id}`, + version: "1.0.0", + dependencies: { + [depName]: "1.0.0", + }, + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: plugin.id, + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + } + const calls: Array<{ missingSpecs: string[]; installSpecs: string[] | undefined }> = []; + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + }, + }, + bundledRuntimeDepsInstaller: ({ installRoot, missingSpecs, installSpecs }) => { + calls.push({ missingSpecs, installSpecs }); + for (const spec of installSpecs ?? missingSpecs) { + const name = spec.split("@")[0] || spec; + fs.mkdirSync(path.join(installRoot, "node_modules", name), { recursive: true }); + fs.writeFileSync( + path.join(installRoot, "node_modules", name, "package.json"), + JSON.stringify({ name, version: "1.0.0" }), + "utf-8", + ); + } + }, + }); + + expect(registry.plugins.map((entry) => entry.id)).toEqual(["alpha", "beta"]); + expect(calls).toEqual([ + { + missingSpecs: ["alpha-runtime@1.0.0"], + installSpecs: ["alpha-runtime@1.0.0"], + }, + { + missingSpecs: ["beta-runtime@1.0.0"], + installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@1.0.0"], + }, + ]); + }); + it("registers standalone text transforms", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 42b52a78e72..30b7a1f0451 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -30,6 +30,11 @@ import { import { resolveUserPath } from "../utils.js"; import { buildPluginApi } from "./api-builder.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; +import { + ensureBundledPluginRuntimeDeps, + resolveBundledRuntimeDependencyInstallRoot, + type BundledRuntimeDepsInstallParams, +} from "./bundled-runtime-deps.js"; import { clearPluginCommands } from "./command-registry-state.js"; import { clearCompactionProviders, @@ -136,6 +141,7 @@ export type PluginLoadOptions = { activate?: boolean; loadModules?: boolean; throwOnLoadError?: boolean; + bundledRuntimeDepsInstaller?: (params: BundledRuntimeDepsInstallParams) => void; }; const CLI_METADATA_ENTRY_BASENAMES = [ @@ -1633,6 +1639,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }); const seenIds = new Map(); + const bundledRuntimeDepsRetainSpecsByInstallRoot = new Map(); const memorySlot = normalized.slots.memory; let selectedMemoryPluginId: string | null = null; let memorySlotMatched = false; @@ -1731,9 +1738,40 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi message: record.error, }); }; + const pluginRoot = safeRealpathOrResolve(candidate.rootDir); + + if (shouldLoadModules && candidate.origin === "bundled" && enableState.enabled) { + try { + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot); + const retainSpecs = bundledRuntimeDepsRetainSpecsByInstallRoot.get(installRoot) ?? []; + const installedSpecs = ensureBundledPluginRuntimeDeps({ + pluginId: record.id, + pluginRoot, + env, + config: cfg, + retainSpecs, + installDeps: options.bundledRuntimeDepsInstaller, + }); + if (installedSpecs.length > 0) { + bundledRuntimeDepsRetainSpecsByInstallRoot.set( + installRoot, + [...new Set([...retainSpecs, ...installedSpecs])].toSorted((left, right) => + left.localeCompare(right), + ), + ); + logger.info( + `[plugins] ${record.id} installed bundled runtime deps: ${installedSpecs.join(", ")}`, + ); + } + } catch (error) { + pushPluginLoadError(`failed to install bundled runtime deps: ${String(error)}`); + continue; + } + } const registrationMode = enableState.enabled - ? !validateOnly && + ? shouldLoadModules && + !validateOnly && shouldLoadChannelPluginInSetupRuntime({ manifestChannels: manifestRecord.channels, setupSource: manifestRecord.setupSource, @@ -1904,7 +1942,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - const pluginRoot = safeRealpathOrResolve(candidate.rootDir); const loadSource = (registrationMode === "setup-only" || registrationMode === "setup-runtime") && manifestRecord.setupSource diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index 2a48c07b0b1..163822b6cd6 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -143,6 +143,12 @@ describe("resolveBuildAllSteps", () => { ]); }); + it("does not cache plugin-sdk entry shims over compiled JS", () => { + const step = BUILD_ALL_STEPS.find((entry) => entry.label === "write-plugin-sdk-entry-dts"); + expect(step).toBeTruthy(); + expect(step?.cache).toBeUndefined(); + }); + it("rejects unknown build profiles", () => { expect(() => resolveBuildAllSteps("wat")).toThrow("Unknown build profile: wat"); });