fix(plugins): repair configured runtime deps

This commit is contained in:
Peter Steinberger
2026-04-30 04:13:17 +01:00
parent db18323551
commit 09310931cf
9 changed files with 259 additions and 92 deletions

View File

@@ -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.

View File

@@ -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.
</Accordion>
<Accordion title="7b. Bundled plugin runtime deps">
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.

View File

@@ -38,6 +38,23 @@ function writeBundledChannelOwnerPlugin(
});
}
function writeBundledProviderPlugin(
root: string,
id: string,
providers: 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,
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" });

View File

@@ -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<string>();
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<string, unknown>;
try {
manifest = JSON.parse(
fs.readFileSync(path.join(pluginDir, "openclaw.plugin.json"), "utf-8"),
) as Record<string, unknown>;
} 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<string, unknown> | 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,
});

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function addConfiguredProviderId(providerIds: Set<string>, value: unknown): void {
if (typeof value !== "string") {
return;
}
const normalized = normalizeProviderId(value);
if (normalized) {
providerIds.add(normalized);
}
}
function addConfiguredProviderFromModelRef(providerIds: Set<string>, value: unknown): void {
if (typeof value !== "string") {
return;
}
const providerId = value.split("/", 1)[0]?.trim();
addConfiguredProviderId(providerIds, providerId);
}
function addConfiguredProvidersFromModelConfig(providerIds: Set<string>, 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<string> {
const providerIds = new Set<string>();
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: {

View File

@@ -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 }) => {

View File

@@ -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 {

View File

@@ -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();

View File

@@ -46,6 +46,7 @@ export function writeBundledPluginRuntimeDepsPackage(params: {
deps: Record<string, string>;
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;