fix: defer bundled plugin runtime deps until enabled

This commit is contained in:
Peter Steinberger
2026-04-23 03:16:44 +01:00
parent 4479d4d437
commit 90696bffff
7 changed files with 309 additions and 61 deletions

View File

@@ -549,7 +549,7 @@ describe("bundled channel entry shape guards", () => {
}
});
it("loads bundled setup entries from external staged runtime deps", async () => {
it("does not load bundled setup entries through external staged runtime deps during discovery", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-setup-runtime-deps-"));
const stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-stage-"));
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
@@ -620,8 +620,8 @@ describe("bundled channel entry shape guards", () => {
"./bundled.js?scope=bundled-setup-runtime-deps",
);
expect(bundled.getBundledChannelSetupPlugin("alpha")?.meta.label).toBe("staged-alpha");
expect(testGlobal.__bundledSetupRuntimeDepMarker).toBe("staged-alpha");
expect(bundled.getBundledChannelSetupPlugin("alpha")).toBeUndefined();
expect(testGlobal.__bundledSetupRuntimeDepMarker).toBeUndefined();
} finally {
restoreBundledPluginsDir(previousBundledPluginsDir);
if (previousPluginStageDir === undefined) {

View File

@@ -4,6 +4,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
import type {
BundledChannelLegacySessionSurface,
BundledChannelLegacyStateMigrationDetector,
BundledEntryModuleLoadOptions,
} from "../../plugin-sdk/channel-entry-contract.js";
import {
listBundledChannelPluginMetadata,
@@ -39,8 +40,10 @@ type BundledChannelEntryRuntimeContract = {
type BundledChannelSetupEntryRuntimeContract = {
kind: "bundled-channel-setup-entry";
loadSetupPlugin: () => ChannelPlugin;
loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined;
loadSetupPlugin: (options?: BundledEntryModuleLoadOptions) => ChannelPlugin;
loadSetupSecrets?: (
options?: BundledEntryModuleLoadOptions,
) => ChannelPlugin["secrets"] | undefined;
loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector;
loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface;
features?: {
@@ -179,6 +182,7 @@ function loadGeneratedBundledChannelModule(params: {
rootScope: BundledChannelRootScope;
metadata: BundledChannelPluginMetadata;
entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"];
installRuntimeDeps?: boolean;
}): unknown {
let modulePath = resolveGeneratedBundledChannelModulePath(params);
if (!modulePath) {
@@ -191,7 +195,7 @@ function loadGeneratedBundledChannelModule(params: {
metadata: params.metadata,
modulePath,
});
if (isBuiltBundledPluginRuntimeRoot(boundaryRoot)) {
if (params.installRuntimeDeps !== false && isBuiltBundledPluginRuntimeRoot(boundaryRoot)) {
const prepared = prepareBundledPluginRuntimeRoot({
pluginId: params.metadata.manifest.id,
pluginRoot: boundaryRoot,
@@ -225,6 +229,7 @@ function loadGeneratedBundledChannelEntry(params: {
rootScope: params.rootScope,
metadata: params.metadata,
entry: params.metadata.source,
installRuntimeDeps: true,
}),
);
if (!entry) {
@@ -257,6 +262,7 @@ function loadGeneratedBundledChannelSetupEntry(params: {
rootScope: params.rootScope,
metadata: params.metadata,
entry: params.metadata.setupSource,
installRuntimeDeps: false,
}),
);
if (!setupEntry) {
@@ -563,7 +569,7 @@ function getBundledChannelSetupPluginForRoot(
}
cacheContext.setupPluginLoadInProgressIds.add(id);
try {
const plugin = entry.loadSetupPlugin();
const plugin = entry.loadSetupPlugin({ installRuntimeDeps: false });
cacheContext.lazySetupPluginsById.set(id, plugin);
return plugin;
} catch (error) {

View File

@@ -107,14 +107,20 @@ export type BundledChannelEntryContract<TPlugin = ChannelPlugin> = {
export type BundledChannelSetupEntryContract<TPlugin = ChannelPlugin> = {
kind: "bundled-channel-setup-entry";
loadSetupPlugin: () => TPlugin;
loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined;
loadSetupPlugin: (options?: BundledEntryModuleLoadOptions) => TPlugin;
loadSetupSecrets?: (
options?: BundledEntryModuleLoadOptions,
) => ChannelPlugin["secrets"] | undefined;
loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector;
loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface;
setChannelRuntime?: (runtime: PluginRuntime) => void;
features?: BundledChannelSetupEntryFeatures;
};
export type BundledEntryModuleLoadOptions = {
installRuntimeDeps?: boolean;
};
const nodeRequire = createRequire(import.meta.url);
const jitiLoaders: PluginJitiLoaderCache = new Map();
const loadedModuleExports = new Map<string, unknown>();
@@ -330,10 +336,14 @@ function canTryNodeRequireBuiltModule(modulePath: string): boolean {
);
}
function loadBundledEntryModuleSync(importMetaUrl: string, specifier: string): unknown {
function loadBundledEntryModuleSync(
importMetaUrl: string,
specifier: string,
options: BundledEntryModuleLoadOptions = {},
): unknown {
let modulePath = resolveBundledEntryModulePath(importMetaUrl, specifier);
const boundaryRoot = resolveEntryBoundaryRoot(importMetaUrl);
if (isBuiltBundledPluginRuntimeRoot(boundaryRoot)) {
if (options.installRuntimeDeps !== false && isBuiltBundledPluginRuntimeRoot(boundaryRoot)) {
const prepared = prepareBundledPluginRuntimeRoot({
pluginId: path.basename(boundaryRoot),
pluginRoot: boundaryRoot,
@@ -396,8 +406,9 @@ function loadBundledEntryModuleSync(importMetaUrl: string, specifier: string): u
export function loadBundledEntryExportSync<T>(
importMetaUrl: string,
reference: BundledEntryModuleRef,
options?: BundledEntryModuleLoadOptions,
): T {
const loaded = loadBundledEntryModuleSync(importMetaUrl, reference.specifier);
const loaded = loadBundledEntryModuleSync(importMetaUrl, reference.specifier, options);
const resolved =
loaded && typeof loaded === "object" && "default" in (loaded as Record<string, unknown>)
? (loaded as { default: unknown }).default
@@ -523,13 +534,15 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
: undefined;
return {
kind: "bundled-channel-setup-entry",
loadSetupPlugin: () => loadBundledEntryExportSync<TPlugin>(importMetaUrl, plugin),
loadSetupPlugin: (options) =>
loadBundledEntryExportSync<TPlugin>(importMetaUrl, plugin, options),
...(secrets
? {
loadSetupSecrets: () =>
loadSetupSecrets: (options) =>
loadBundledEntryExportSync<ChannelPlugin["secrets"] | undefined>(
importMetaUrl,
secrets,
options,
),
}
: {}),

View File

@@ -1051,6 +1051,193 @@ module.exports = {
expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("disabled");
});
it("does not repair disabled selected setup-only channel runtime deps", () => {
const bundledDir = makeTempDir();
const plugin = writePlugin({
id: "feishu",
dir: path.join(bundledDir, "feishu"),
filename: "index.cjs",
body: `module.exports = { id: "feishu", register() {} };`,
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
fs.writeFileSync(
path.join(plugin.dir, "package.json"),
JSON.stringify(
{
name: "@openclaw/feishu",
version: "1.0.0",
dependencies: {
"feishu-runtime": "1.0.0",
},
openclaw: {
extensions: ["./index.cjs"],
setupEntry: "./setup-entry.cjs",
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "feishu",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["feishu"],
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "setup-entry.cjs"),
`
module.exports = {
plugin: {
id: "feishu",
meta: {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu",
docsPath: "/channels/feishu",
blurb: "setup only",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
},
};
`,
"utf-8",
);
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
enabled: true,
entries: {
feishu: { enabled: false },
},
},
},
includeSetupOnlyChannelPlugins: true,
onlyPluginIds: ["feishu"],
bundledRuntimeDepsInstaller: () => {
throw new Error("disabled setup-only deps should not install");
},
});
expect(registry.channelSetups[0]?.plugin.meta.label).toBe("Feishu");
expect(registry.plugins.find((entry) => entry.id === "feishu")?.status).toBe("disabled");
});
it("repairs enabled selected setup-only channel runtime deps before loading setup entry", () => {
const bundledDir = makeTempDir();
const plugin = writePlugin({
id: "feishu",
dir: path.join(bundledDir, "feishu"),
filename: "index.cjs",
body: `module.exports = { id: "feishu", register() {} };`,
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
fs.writeFileSync(
path.join(plugin.dir, "package.json"),
JSON.stringify(
{
name: "@openclaw/feishu",
version: "1.0.0",
dependencies: {
"feishu-runtime": "1.0.0",
},
openclaw: {
extensions: ["./index.cjs"],
setupEntry: "./setup-entry.cjs",
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "feishu",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["feishu"],
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "setup-entry.cjs"),
`
const runtime = require("feishu-runtime");
module.exports = {
plugin: {
id: "feishu",
meta: {
id: "feishu",
label: runtime.label,
selectionLabel: runtime.label,
docsPath: "/channels/feishu",
blurb: "setup only",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
},
};
`,
"utf-8",
);
const installedSpecs: string[] = [];
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
enabled: true,
entries: {
feishu: { enabled: true },
},
},
},
includeSetupOnlyChannelPlugins: true,
onlyPluginIds: ["feishu"],
bundledRuntimeDepsInstaller: ({ installRoot, missingSpecs }) => {
installedSpecs.push(...missingSpecs);
const depRoot = path.join(installRoot, "node_modules", "feishu-runtime");
fs.mkdirSync(depRoot, { recursive: true });
fs.writeFileSync(
path.join(depRoot, "package.json"),
JSON.stringify({ name: "feishu-runtime", version: "1.0.0", main: "index.cjs" }),
"utf-8",
);
fs.writeFileSync(
path.join(depRoot, "index.cjs"),
"module.exports = { label: 'Feishu Runtime Ready' };\n",
"utf-8",
);
},
});
expect(installedSpecs).toEqual(["feishu-runtime@1.0.0"]);
expect(registry.channelSetups[0]?.plugin.meta.label).toBe("Feishu Runtime Ready");
expect(registry.plugins.find((entry) => entry.id === "feishu")?.status).toBe("loaded");
});
it("repairs default-enabled bundled plugin runtime deps", () => {
const bundledDir = makeTempDir();
const plugin = writePlugin({

View File

@@ -1232,7 +1232,10 @@ function loadBundledRuntimeChannelPlugin(params: {
}
}
function resolveSetupChannelRegistration(moduleExport: unknown): {
function resolveSetupChannelRegistration(
moduleExport: unknown,
params: { installRuntimeDeps?: boolean } = {},
): {
plugin?: ChannelPlugin;
setChannelRuntime?: (runtime: PluginRuntime) => void;
usesBundledSetupContract?: boolean;
@@ -1253,10 +1256,14 @@ function resolveSetupChannelRegistration(moduleExport: unknown): {
typeof setupEntryRecord.loadSetupPlugin === "function"
) {
try {
const loadedPlugin = setupEntryRecord.loadSetupPlugin();
const setupLoadOptions =
params.installRuntimeDeps === false ? { installRuntimeDeps: false } : undefined;
const loadedPlugin = setupEntryRecord.loadSetupPlugin(setupLoadOptions);
const loadedSecrets =
typeof setupEntryRecord.loadSetupSecrets === "function"
? (setupEntryRecord.loadSetupSecrets() as ChannelPlugin["secrets"] | undefined)
? (setupEntryRecord.loadSetupSecrets(setupLoadOptions) as
| ChannelPlugin["secrets"]
| undefined)
: undefined;
if (loadedPlugin && typeof loadedPlugin === "object") {
const mergedSecrets = mergeChannelPluginSection(
@@ -2062,6 +2069,49 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
let runtimeCandidateSource = candidate.source;
let runtimeSetupSource = manifestRecord.setupSource;
const scopedSetupOnlyChannelPluginRequested =
includeSetupOnlyChannelPlugins &&
!validateOnly &&
onlyPluginIdSet &&
manifestRecord.channels.length > 0 &&
(!enableState.enabled || forceSetupOnlyChannelPlugins);
const canLoadScopedSetupOnlyChannelPlugin =
scopedSetupOnlyChannelPluginRequested &&
(!requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource));
const registrationMode = canLoadScopedSetupOnlyChannelPlugin
? "setup-only"
: scopedSetupOnlyChannelPluginRequested && requireSetupEntryForSetupOnlyChannelPlugins
? null
: enableState.enabled
? shouldLoadModules &&
!validateOnly &&
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: manifestRecord.channels,
setupSource: manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
})
? "setup-runtime"
: "full"
: null;
if (!registrationMode) {
record.status = "disabled";
record.error = enableState.reason;
markPluginActivationDisabled(record, enableState.reason);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
markPluginActivationDisabled(record, enableState.reason);
}
if (shouldLoadModules && candidate.origin === "bundled" && enableState.enabled) {
try {
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env });
@@ -2112,49 +2162,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
const scopedSetupOnlyChannelPluginRequested =
includeSetupOnlyChannelPlugins &&
!validateOnly &&
onlyPluginIdSet &&
manifestRecord.channels.length > 0 &&
(!enableState.enabled || forceSetupOnlyChannelPlugins);
const canLoadScopedSetupOnlyChannelPlugin =
scopedSetupOnlyChannelPluginRequested &&
(!requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource));
const registrationMode = canLoadScopedSetupOnlyChannelPlugin
? "setup-only"
: scopedSetupOnlyChannelPluginRequested && requireSetupEntryForSetupOnlyChannelPlugins
? null
: enableState.enabled
? shouldLoadModules &&
!validateOnly &&
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: manifestRecord.channels,
setupSource: manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
})
? "setup-runtime"
: "full"
: null;
if (!registrationMode) {
record.status = "disabled";
record.error = enableState.reason;
markPluginActivationDisabled(record, enableState.reason);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
markPluginActivationDisabled(record, enableState.reason);
}
if (record.format === "bundle") {
const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter(
(capability) =>
@@ -2345,7 +2352,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
manifestRecord.setupSource
) {
const setupRegistration = resolveSetupChannelRegistration(mod);
const setupRegistration = resolveSetupChannelRegistration(mod, {
installRuntimeDeps: enableState.enabled,
});
if (setupRegistration.loadError) {
recordPluginError({
logger,

View File

@@ -520,6 +520,38 @@ describe("runSetupWizard", () => {
}
});
it("defers channel setup plugin loads during QuickStart until a channel is selected", async () => {
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipProviders: true,
skipChannels: false,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
expect(setupChannels).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
deferStatusUntilSelection: true,
quickstartDefaults: true,
}),
);
});
it("prompts for a model during explicit interactive Ollama setup", async () => {
promptDefaultModel.mockClear();
resolveProviderPluginChoice.mockReturnValue({

View File

@@ -618,6 +618,7 @@ export async function runSetupWizard(
: [];
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
allowSignalInstall: true,
deferStatusUntilSelection: flow === "quickstart",
forceAllowFromChannels: quickstartAllowFromChannels,
skipDmPolicyPrompt: flow === "quickstart",
skipConfirm: flow === "quickstart",