diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 2eb0b3cfed4..310b1ad3a79 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -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) { diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 92f79dc7341..2fcfa545f02 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -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) { diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 1a5c4271543..ef89ff7fcef 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -107,14 +107,20 @@ export type BundledChannelEntryContract = { export type BundledChannelSetupEntryContract = { 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(); @@ -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( 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) ? (loaded as { default: unknown }).default @@ -523,13 +534,15 @@ export function defineBundledChannelSetupEntry({ : undefined; return { kind: "bundled-channel-setup-entry", - loadSetupPlugin: () => loadBundledEntryExportSync(importMetaUrl, plugin), + loadSetupPlugin: (options) => + loadBundledEntryExportSync(importMetaUrl, plugin, options), ...(secrets ? { - loadSetupSecrets: () => + loadSetupSecrets: (options) => loadBundledEntryExportSync( importMetaUrl, secrets, + options, ), } : {}), diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 7436af06944..55a0aa330ce 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index f86366aa554..bab73e4e33a 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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, diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 7fd3de0206d..ec434948835 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -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({ diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index ec5b0e848a8..787add61959 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -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",