diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 85fa2e6c2dd..43b76d256d3 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -265,7 +265,7 @@ openclaw plugins deps --prune openclaw plugins deps --json ``` -`plugins deps` inspects the packaged runtime dependency stage for OpenClaw-owned bundled plugins. It is not the install/update path for third-party npm or ClawHub plugins. +`plugins deps` inspects the packaged runtime dependency stage for OpenClaw-owned bundled plugins selected by plugin config, enabled/configured channels, configured model providers, or bundled manifest defaults. It is not the install/update path for third-party npm or ClawHub plugins. Use `--repair` when a packaged install reports missing bundled runtime dependencies during Gateway startup or `plugins doctor`. Repair installs only missing enabled bundled-plugin deps with lifecycle scripts disabled. Use `--prune` to remove stale unknown external runtime-dependency roots left behind by older packaged layouts. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index cb618532a60..35de3617da6 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -333,7 +333,7 @@ That stages grounded durable candidates into the short-term dreaming store while When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing. - 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. + 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`, configured `models.providers.*` / agent model refs, or a default-enabled bundled plugin without provider ownership. 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. During doctor repair, bundled runtime-dependency npm installs report spinner progress in TTY sessions and periodic line progress in piped/headless output. The Gateway and local CLI can also repair active bundled plugin runtime dependencies on demand before importing a bundled plugin. These installs are scoped to the plugin runtime install root, run with scripts disabled, do not write a package lock, and are guarded by an install-root lock so concurrent CLI or Gateway starts do not mutate the same `node_modules` tree at the same time. diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index 60989d91f28..5cccfaadb4e 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -38,6 +38,23 @@ function writeBundledChannelOwnerPlugin( }); } +function writeBundledProviderPlugin( + root: string, + id: string, + providers: string[], + dependencies: Record, +) { + writeJson(path.join(root, "dist", "extensions", id, "package.json"), { + dependencies, + }); + writeJson(path.join(root, "dist", "extensions", id, "openclaw.plugin.json"), { + id, + providers, + enabledByDefault: true, + configSchema: { type: "object" }, + }); +} + function writeDefaultEnabledBundledChannelPlugin( root: string, id: string, @@ -528,6 +545,87 @@ describe("doctor bundled plugin runtime deps", () => { ]); }); + it("repairs configured provider deps", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledProviderPlugin(root, "anthropic-vertex", ["anthropic-vertex"], { + "@anthropic-ai/vertex-sdk": "^0.16.0", + }); + const installed = createInstalledRuntimeDeps(); + + await maybeRepairBundledPluginRuntimeDeps({ + runtime: createRuntime(), + prompter: createNonInteractivePrompter(), + packageRoot: root, + config: { + plugins: { enabled: true }, + agents: { + defaults: { + model: "anthropic-vertex/claude-sonnet-4-6", + }, + }, + }, + installDeps: (params) => { + installed.push(params); + materializeRuntimeDeps(params); + }, + }); + + expect(installed).toEqual([ + { + installRoot: resolveBundledRuntimeDependencyPackageInstallRoot(root), + missingSpecs: ["@anthropic-ai/vertex-sdk@^0.16.0"], + installSpecs: ["@anthropic-ai/vertex-sdk@^0.16.0"], + }, + ]); + }); + + it("repairs configured provider deps from provider aliases and subagent defaults", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledProviderPlugin(root, "amazon-bedrock", ["amazon-bedrock"], { + "bedrock-only": "1.0.0", + }); + const installed = createInstalledRuntimeDeps(); + + await maybeRepairBundledPluginRuntimeDeps({ + runtime: createRuntime(), + prompter: createNonInteractivePrompter(), + packageRoot: root, + config: { + plugins: { enabled: true }, + models: { + providers: { + "aws-bedrock": { + baseUrl: "", + auth: "aws-sdk", + models: [], + }, + }, + }, + agents: { + defaults: { + subagents: { + model: "bedrock/claude-sonnet-4-6", + }, + }, + }, + }, + installDeps: (params) => { + installed.push(params); + materializeRuntimeDeps(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" }); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index 0c79d297ed7..b62e46b29da 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -1,5 +1,3 @@ -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"; @@ -10,84 +8,12 @@ import { scanBundledPluginRuntimeDeps, type BundledRuntimeDepsInstallParams, } from "../plugins/bundled-runtime-deps.js"; -import { normalizePluginsConfig } from "../plugins/config-state.js"; -import { passesManifestOwnerBasePolicy } from "../plugins/manifest-owner-policy.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; const RUNTIME_DEPS_INSTALL_HEARTBEAT_MS = 15_000; -function collectPackagedRuntimeDepsRepairPluginIds(params: { - bundledPluginsDir: string; - config: OpenClawConfig; - includeConfiguredChannels?: boolean; -}): string[] { - if (!fs.existsSync(params.bundledPluginsDir)) { - return []; - } - const plugins = normalizePluginsConfig(params.config.plugins); - const ids = new Set(); - for (const entry of fs.readdirSync(params.bundledPluginsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - const pluginDir = path.join(params.bundledPluginsDir, entry.name); - let manifest: Record; - try { - manifest = JSON.parse( - fs.readFileSync(path.join(pluginDir, "openclaw.plugin.json"), "utf-8"), - ) as Record; - } catch { - continue; - } - const pluginId = typeof manifest.id === "string" && manifest.id ? manifest.id : entry.name; - if ( - !passesManifestOwnerBasePolicy({ - plugin: { id: pluginId }, - normalizedConfig: plugins, - allowRestrictiveAllowlistBypass: true, - }) - ) { - continue; - } - if (plugins.allow.includes(pluginId) || plugins.entries[pluginId]?.enabled === true) { - ids.add(pluginId); - continue; - } - const channels = Array.isArray(manifest.channels) - ? manifest.channels.filter((channel): channel is string => typeof channel === "string") - : []; - if ( - channels.some((channelId) => { - const channelConfig = (params.config.channels as Record | undefined)?.[ - channelId - ]; - if (!channelConfig || typeof channelConfig !== "object" || Array.isArray(channelConfig)) { - return false; - } - if ((channelConfig as { enabled?: unknown }).enabled === false) { - return false; - } - return ( - (channelConfig as { enabled?: unknown }).enabled === true || - params.includeConfiguredChannels === true - ); - }) - ) { - ids.add(pluginId); - continue; - } - const providers = Array.isArray(manifest.providers) - ? manifest.providers.filter((provider): provider is string => typeof provider === "string") - : []; - if (manifest.enabledByDefault === true && providers.length === 0 && channels.length === 0) { - ids.add(pluginId); - } - } - return [...ids].toSorted((left, right) => left.localeCompare(right)); -} - function formatElapsedMs(elapsedMs: number): string { if (elapsedMs < 1000) { return `${elapsedMs}ms`; @@ -126,18 +52,9 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { } const env = params.env ?? process.env; - const bundledPluginsDir = path.join(packageRoot, "dist", "extensions"); - const effectivePluginIds = params.config - ? collectPackagedRuntimeDepsRepairPluginIds({ - bundledPluginsDir, - config: params.config, - includeConfiguredChannels: params.includeConfiguredChannels, - }) - : undefined; const { deps, missing, conflicts } = scanBundledPluginRuntimeDeps({ packageRoot, config: params.config, - pluginIds: effectivePluginIds, includeConfiguredChannels: params.includeConfiguredChannels, env, }); diff --git a/src/plugins/bundled-runtime-deps-selection.ts b/src/plugins/bundled-runtime-deps-selection.ts index cef954cc381..a3ec086c535 100644 --- a/src/plugins/bundled-runtime-deps-selection.ts +++ b/src/plugins/bundled-runtime-deps-selection.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { readRuntimeDepsJsonObject, type JsonObject } from "./bundled-runtime-deps-json.js"; @@ -207,6 +208,88 @@ function passesRuntimeDepsPluginPolicy(params: { ); } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function addConfiguredProviderId(providerIds: Set, value: unknown): void { + if (typeof value !== "string") { + return; + } + const normalized = normalizeProviderId(value); + if (normalized) { + providerIds.add(normalized); + } +} + +function addConfiguredProviderFromModelRef(providerIds: Set, value: unknown): void { + if (typeof value !== "string") { + return; + } + const providerId = value.split("/", 1)[0]?.trim(); + addConfiguredProviderId(providerIds, providerId); +} + +function addConfiguredProvidersFromModelConfig(providerIds: Set, value: unknown): void { + if (typeof value === "string") { + addConfiguredProviderFromModelRef(providerIds, value); + return; + } + if (!isRecord(value)) { + return; + } + addConfiguredProviderFromModelRef(providerIds, value.primary); + if (Array.isArray(value.fallbacks)) { + for (const fallback of value.fallbacks) { + addConfiguredProviderFromModelRef(providerIds, fallback); + } + } +} + +function collectConfiguredProviderIds(config: OpenClawConfig): Set { + const providerIds = new Set(); + for (const providerId of Object.keys(config.models?.providers ?? {})) { + addConfiguredProviderId(providerIds, providerId); + } + for (const profile of Object.values(config.auth?.profiles ?? {})) { + addConfiguredProviderId(providerIds, profile.provider); + } + for (const providerId of Object.keys(config.auth?.order ?? {})) { + addConfiguredProviderId(providerIds, providerId); + } + + const defaults = config.agents?.defaults; + addConfiguredProvidersFromModelConfig(providerIds, defaults?.model); + addConfiguredProvidersFromModelConfig(providerIds, defaults?.imageModel); + addConfiguredProvidersFromModelConfig(providerIds, defaults?.imageGenerationModel); + addConfiguredProvidersFromModelConfig(providerIds, defaults?.videoGenerationModel); + addConfiguredProvidersFromModelConfig(providerIds, defaults?.musicGenerationModel); + addConfiguredProvidersFromModelConfig(providerIds, defaults?.pdfModel); + addConfiguredProvidersFromModelConfig(providerIds, defaults?.subagents?.model); + for (const providerId of Object.keys(defaults?.models ?? {})) { + addConfiguredProviderFromModelRef(providerIds, providerId); + } + + for (const agent of config.agents?.list ?? []) { + addConfiguredProvidersFromModelConfig(providerIds, agent.model); + addConfiguredProvidersFromModelConfig(providerIds, agent.subagents?.model); + } + return providerIds; +} + +function isBundledProviderConfiguredForRuntimeDeps(params: { + config: OpenClawConfig; + providers: readonly string[]; +}): boolean { + if (params.providers.length === 0) { + return false; + } + const configuredProviderIds = collectConfiguredProviderIds(params.config); + return params.providers.some((provider) => + configuredProviderIds.has(normalizeProviderId(provider)), + ); +} + export function isBundledPluginConfiguredForRuntimeDeps(params: { config: OpenClawConfig; plugins: NormalizedPluginsConfig; @@ -280,7 +363,15 @@ export function isBundledPluginConfiguredForRuntimeDeps(params: { if (hasConfiguredChannel) { return true; } - return manifest.enabledByDefault; + if ( + isBundledProviderConfiguredForRuntimeDeps({ + config: params.config, + providers: manifest.providers, + }) + ) { + return true; + } + return manifest.enabledByDefault && manifest.providers.length === 0; } function isBundledPluginExplicitlyDisabledForRuntimeDeps(params: { diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 5985248173f..00c75f8bd35 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -1037,6 +1037,13 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { deps: { "telegram-runtime": "2.0.0" }, channels: ["telegram"], }); + writeBundledPluginPackage({ + packageRoot, + pluginId: "amazon-bedrock", + deps: { "bedrock-runtime": "3.0.0" }, + enabledByDefault: true, + providers: ["amazon-bedrock"], + }); return packageRoot; } @@ -1134,6 +1141,33 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { includeConfiguredChannels: true, expectedDeps: ["alpha-runtime@1.0.0"], }, + { + name: "includes configured model provider deps", + config: { agents: { defaults: { model: "amazon-bedrock/claude-opus-4-7" } } }, + includeConfiguredChannels: false, + expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"], + }, + { + name: "includes configured model provider deps from aliases", + config: { models: { providers: { "aws-bedrock": { baseUrl: "", models: [] } } } }, + includeConfiguredChannels: false, + expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"], + }, + { + name: "includes configured subagent model provider deps", + config: { agents: { defaults: { subagents: { model: "bedrock/claude-sonnet-4-6" } } } }, + includeConfiguredChannels: false, + expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"], + }, + { + name: "keeps configured provider deps behind restrictive allowlists", + config: { + plugins: { allow: ["alpha"] }, + agents: { defaults: { model: "amazon-bedrock/claude-opus-4-7" } }, + }, + includeConfiguredChannels: false, + expectedDeps: ["alpha-runtime@1.0.0"], + }, ]; it.each(cases)("$name", ({ config, includeConfiguredChannels, expectedDeps }) => { diff --git a/src/plugins/bundled-runtime-mirror.ts b/src/plugins/bundled-runtime-mirror.ts index 7ec5ade9c27..e382d17cad5 100644 --- a/src/plugins/bundled-runtime-mirror.ts +++ b/src/plugins/bundled-runtime-mirror.ts @@ -91,14 +91,20 @@ export function materializeBundledRuntimeMirrorFile(sourcePath: string, targetPa // Missing targets are expected before the mirror file is materialized. } fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 }); - fs.rmSync(targetPath, { recursive: true, force: true }); + removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "file"); + const tempPath = createBundledRuntimeMirrorTempPath(targetPath); try { - fs.linkSync(sourcePath, targetPath); - return; - } catch { - fs.copyFileSync(sourcePath, targetPath); + try { + fs.linkSync(sourcePath, tempPath); + } catch { + fs.copyFileSync(sourcePath, tempPath); + chmodBundledRuntimeMirrorFileReadable(sourcePath, tempPath); + } + fs.renameSync(tempPath, targetPath); + } catch (error) { + fs.rmSync(tempPath, { force: true }); + throw error; } - chmodBundledRuntimeMirrorFileReadable(sourcePath, targetPath); } function chmodBundledRuntimeMirrorFileReadable(sourcePath: string, targetPath: string): void { diff --git a/src/plugins/bundled-runtime-root.test.ts b/src/plugins/bundled-runtime-root.test.ts index 630cc76d229..d07829a482b 100644 --- a/src/plugins/bundled-runtime-root.test.ts +++ b/src/plugins/bundled-runtime-root.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveBundledRuntimeDependencyInstallRoot } from "./bundled-runtime-deps.js"; +import { materializeBundledRuntimeMirrorFile } from "./bundled-runtime-mirror.js"; import { prepareBundledPluginRuntimeRoot } from "./bundled-runtime-root.js"; import { writeGeneratedRuntimeDepsManifest } from "./test-helpers/bundled-runtime-deps-fixtures.js"; @@ -37,6 +38,24 @@ function isBigIntStatOptions(options: unknown): boolean { } describe("prepareBundledPluginRuntimeRoot", () => { + it("keeps existing materialized root chunks when copy refresh fails", () => { + const root = makeTempRoot(); + const source = path.join(root, "source.js"); + const target = path.join(root, "mirror", "source.js"); + fs.writeFileSync(source, "export const value = 'new';\n", "utf8"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, "export const value = 'old';\n", "utf8"); + vi.spyOn(fs, "linkSync").mockImplementation(() => { + throw new Error("EXDEV"); + }); + vi.spyOn(fs, "copyFileSync").mockImplementation(() => { + throw new Error("ENOSPC"); + }); + + expect(() => materializeBundledRuntimeMirrorFile(source, target)).toThrow("ENOSPC"); + expect(fs.readFileSync(target, "utf8")).toBe("export const value = 'old';\n"); + }); + it("materializes root JavaScript chunks in external mirrors", () => { const packageRoot = makeTempRoot(); const stageDir = makeTempRoot(); diff --git a/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts b/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts index f8976554cb5..1e926d1d675 100644 --- a/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts +++ b/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts @@ -46,6 +46,7 @@ export function writeBundledPluginRuntimeDepsPackage(params: { deps: Record; enabledByDefault?: boolean; channels?: string[]; + providers?: string[]; }): string { const pluginRoot = path.join(params.packageRoot, "dist", "extensions", params.pluginId); fs.mkdirSync(pluginRoot, { recursive: true }); @@ -59,6 +60,7 @@ export function writeBundledPluginRuntimeDepsPackage(params: { id: params.pluginId, enabledByDefault: params.enabledByDefault === true, ...(params.channels ? { channels: params.channels } : {}), + ...(params.providers ? { providers: params.providers } : {}), }), ); return pluginRoot;