diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 4113c9fbd05..2a5b5d37006 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -749,7 +749,8 @@ A plugin directory may include a `package.json` with `openclaw.extensions`: { "name": "my-pack", "openclaw": { - "extensions": ["./src/safety.ts", "./src/tools.ts"] + "extensions": ["./src/safety.ts", "./src/tools.ts"], + "setupEntry": "./src/setup-entry.ts" } } ``` @@ -768,6 +769,12 @@ Security note: `openclaw plugins install` installs plugin dependencies with `npm install --ignore-scripts` (no lifecycle scripts). Keep plugin dependency trees "pure JS/TS" and avoid packages that require `postinstall` builds. +Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. +When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, it +loads `setupEntry` instead of the full plugin entry. This keeps startup and +onboarding lighter when your main plugin entry also wires tools, hooks, or +other runtime-only code. + ### Channel catalog metadata Channel plugins can advertise onboarding metadata via `openclaw.channel` and @@ -1657,6 +1664,7 @@ Recommended packaging: Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. +- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled channel onboarding/setup. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 67df516b8d7..2426958d346 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "bluebubbles", "label": "BlueBubbles", diff --git a/extensions/bluebubbles/setup-entry.ts b/extensions/bluebubbles/setup-entry.ts new file mode 100644 index 00000000000..5e05d9c8bb2 --- /dev/null +++ b/extensions/bluebubbles/setup-entry.ts @@ -0,0 +1,5 @@ +import { bluebubblesPlugin } from "./src/channel.js"; + +export default { + plugin: bluebubblesPlugin, +}; diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 805dd389b0a..d5dfe64f369 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -13,6 +13,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "feishu", "label": "Feishu", diff --git a/extensions/feishu/setup-entry.ts b/extensions/feishu/setup-entry.ts new file mode 100644 index 00000000000..3e4df4faee8 --- /dev/null +++ b/extensions/feishu/setup-entry.ts @@ -0,0 +1,5 @@ +import { feishuPlugin } from "./src/channel.js"; + +export default { + plugin: feishuPlugin, +}; diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 3514ac52b90..2c4469163db 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -19,6 +19,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "googlechat", "label": "Google Chat", diff --git a/extensions/googlechat/setup-entry.ts b/extensions/googlechat/setup-entry.ts new file mode 100644 index 00000000000..7d80304ccf3 --- /dev/null +++ b/extensions/googlechat/setup-entry.ts @@ -0,0 +1,6 @@ +import { googlechatDock, googlechatPlugin } from "./src/channel.js"; + +export default { + plugin: googlechatPlugin, + dock: googlechatDock, +}; diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 8d162b9ac20..774fa993dbd 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -9,6 +9,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/irc/setup-entry.ts b/extensions/irc/setup-entry.ts new file mode 100644 index 00000000000..fe8bea1814d --- /dev/null +++ b/extensions/irc/setup-entry.ts @@ -0,0 +1,5 @@ +import { ircPlugin } from "./src/channel.js"; + +export default { + plugin: ircPlugin, +}; diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 5b973b88635..bebd410fae9 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -15,6 +15,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "matrix", "label": "Matrix", diff --git a/extensions/matrix/setup-entry.ts b/extensions/matrix/setup-entry.ts new file mode 100644 index 00000000000..4cbabfe6333 --- /dev/null +++ b/extensions/matrix/setup-entry.ts @@ -0,0 +1,5 @@ +import { matrixPlugin } from "./src/channel.js"; + +export default { + plugin: matrixPlugin, +}; diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 4784334d1d5..eb02c9cee13 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "msteams", "label": "Microsoft Teams", diff --git a/extensions/msteams/setup-entry.ts b/extensions/msteams/setup-entry.ts new file mode 100644 index 00000000000..fb850b60e18 --- /dev/null +++ b/extensions/msteams/setup-entry.ts @@ -0,0 +1,5 @@ +import { msteamsPlugin } from "./src/channel.js"; + +export default { + plugin: msteamsPlugin, +}; diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index c217d0f0ce7..d594a67b96f 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "nextcloud-talk", "label": "Nextcloud Talk", diff --git a/extensions/nextcloud-talk/setup-entry.ts b/extensions/nextcloud-talk/setup-entry.ts new file mode 100644 index 00000000000..f33df37c7dc --- /dev/null +++ b/extensions/nextcloud-talk/setup-entry.ts @@ -0,0 +1,5 @@ +import { nextcloudTalkPlugin } from "./src/channel.js"; + +export default { + plugin: nextcloudTalkPlugin, +}; diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 40ec9aeedde..071280374a3 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -13,6 +13,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "tlon", "label": "Tlon", diff --git a/extensions/tlon/setup-entry.ts b/extensions/tlon/setup-entry.ts new file mode 100644 index 00000000000..667e917c8da --- /dev/null +++ b/extensions/tlon/setup-entry.ts @@ -0,0 +1,5 @@ +import { tlonPlugin } from "./src/channel.js"; + +export default { + plugin: tlonPlugin, +}; diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 426f319c02c..f5eac7ba513 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -23,6 +23,15 @@ export function rewritePackageExtensions(entries) { }); } +function rewritePackageEntry(entry) { + if (typeof entry !== "string" || entry.trim().length === 0) { + return undefined; + } + const normalized = entry.replace(/^\.\//, ""); + const rewritten = normalized.replace(/\.[^.]+$/u, ".js"); + return `./${rewritten}`; +} + function ensurePathInsideRoot(rootDir, rawPath) { const resolved = path.resolve(rootDir, rawPath); const relative = path.relative(rootDir, resolved); @@ -176,6 +185,9 @@ export function copyBundledPluginMetadata(params = {}) { packageJson.openclaw = { ...packageJson.openclaw, extensions: rewritePackageExtensions(packageJson.openclaw.extensions), + ...(typeof packageJson.openclaw.setupEntry === "string" + ? { setupEntry: rewritePackageEntry(packageJson.openclaw.setupEntry) } + : {}), }; } diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 2376e97100f..7b8fe8b878a 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -91,6 +91,8 @@ describe("registerPreActionHooks", () => { program.command("agents").action(() => {}); program.command("configure").action(() => {}); program.command("onboard").action(() => {}); + const channels = program.command("channels"); + channels.command("add").action(() => {}); program .command("update") .command("status") @@ -167,6 +169,31 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" }); }); + it("keeps onboarding and channels add manifest-first", async () => { + await runPreAction({ + parseArgv: ["onboard"], + processArgv: ["node", "openclaw", "onboard"], + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime: runtimeMock, + commandPath: ["onboard"], + }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + await runPreAction({ + parseArgv: ["channels", "add"], + processArgv: ["node", "openclaw", "channels", "add"], + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime: runtimeMock, + commandPath: ["channels", "add"], + }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); + it("skips help/version preaction and respects banner opt-out", async () => { await runPreAction({ parseArgv: ["status"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 19659f97c7e..edeec669079 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -32,7 +32,6 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([ "directory", "agents", "configure", - "onboard", "status", "health", ]); @@ -72,15 +71,19 @@ function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" { } function shouldLoadPluginsForCommand(commandPath: string[], argv: string[]): boolean { - if (!PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + const [primary, secondary] = commandPath; + if (!primary || !PLUGIN_REQUIRED_COMMANDS.has(primary)) { return false; } - if ((commandPath[0] === "status" || commandPath[0] === "health") && hasFlag(argv, "--json")) { + if ((primary === "status" || primary === "health") && hasFlag(argv, "--json")) { + return false; + } + // Onboarding/setup should stay manifest-first and load selected plugins on demand. + if (primary === "onboard" || (primary === "channels" && secondary === "add")) { return false; } return true; } - function getRootCommand(command: Command): Command { let current = command; while (current.parent) { @@ -148,6 +151,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) ...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), }); // Load plugins for commands that need channel access + if (shouldLoadPluginsForCommand(commandPath, argv)) { if (shouldLoadPluginsForCommand(commandPath, argv)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index e412c60215a..88e1a245906 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -195,7 +195,10 @@ export async function channelsAddCommand( ...(pluginId ? { pluginId } : {}), workspaceDir: resolveWorkspaceDir(), }); - return snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin; + return ( + snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ?? + snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin + ); }; if (!channel && catalogEntry) { diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 81deb95e901..cdb987914bc 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { DmPolicy } from "../config/types.js"; import { enablePluginInConfig } from "../plugins/enable.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; @@ -123,11 +124,16 @@ async function collectChannelStatus(params: { installedPlugins?: ReturnType; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); - const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); - const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter( - (entry) => !installedIds.has(entry.id), + const allCatalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); + const installedChannelIds = new Set( + loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir, + env: process.env, + }).plugins.flatMap((plugin) => plugin.channels), ); + const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id)); const statusEntries = await Promise.all( listChannelOnboardingAdapters().map((adapter) => adapter.getStatus({ @@ -151,6 +157,28 @@ async function collectChannelStatus(params: { quickstartScore: 0, }; }); + const discoveredPluginStatuses = allCatalogEntries + .filter((entry) => installedChannelIds.has(entry.id)) + .filter((entry) => !statusByChannel.has(entry.id as ChannelChoice)) + .map((entry) => { + const configured = isChannelConfigured(params.cfg, entry.id); + const pluginEnabled = + params.cfg.plugins?.entries?.[entry.pluginId ?? entry.id]?.enabled !== false; + const statusLabel = configured + ? pluginEnabled + ? "configured" + : "configured (plugin disabled)" + : pluginEnabled + ? "installed" + : "installed (plugin disabled)"; + return { + channel: entry.id as ChannelChoice, + configured, + statusLines: [`${entry.meta.label}: ${statusLabel}`], + selectionHint: statusLabel, + quickstartScore: 0, + }; + }); const catalogStatuses = catalogEntries.map((entry) => ({ channel: entry.id, configured: false, @@ -158,7 +186,12 @@ async function collectChannelStatus(params: { selectionHint: "plugin ยท install", quickstartScore: 0, })); - const combinedStatuses = [...statusEntries, ...fallbackStatuses, ...catalogStatuses]; + const combinedStatuses = [ + ...statusEntries, + ...fallbackStatuses, + ...discoveredPluginStatuses, + ...catalogStatuses, + ]; const mergedStatusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry])); const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); return { @@ -344,7 +377,9 @@ export async function setupChannels( ...(pluginId ? { pluginId } : {}), workspaceDir: resolveWorkspaceDir(), }); - const plugin = snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin; + const plugin = + snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin ?? + snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin; if (plugin) { rememberScopedPlugin(plugin); } diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index 1cd9e530b86..953fccf5a68 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -292,6 +292,7 @@ describe("ensureOnboardingPluginInstalled", () => { config: cfg, workspaceDir: "/tmp/openclaw-workspace", cache: false, + includeSetupOnlyChannelPlugins: true, }), ); expect(clearPluginDiscoveryCache.mock.invocationCallOrder[0]).toBeLessThan( @@ -316,6 +317,7 @@ describe("ensureOnboardingPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", cache: false, onlyPluginIds: ["telegram"], + includeSetupOnlyChannelPlugins: true, }), ); }); @@ -377,6 +379,7 @@ describe("ensureOnboardingPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", cache: false, onlyPluginIds: ["telegram"], + includeSetupOnlyChannelPlugins: true, activate: false, }), ); @@ -400,6 +403,7 @@ describe("ensureOnboardingPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", cache: false, onlyPluginIds: ["@openclaw/msteams-plugin"], + includeSetupOnlyChannelPlugins: true, activate: false, }), ); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index 31f5ec1d64d..3a7f5623425 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -250,6 +250,7 @@ function loadOnboardingPluginRegistry(params: { cache: false, logger: createPluginLoaderLogger(log), onlyPluginIds: params.onlyPluginIds, + includeSetupOnlyChannelPlugins: true, activate: params.activate, }); } diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index c102ffc80c7..743b0b569f9 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -19,6 +19,7 @@ const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); export type PluginCandidate = { idHint: string; source: string; + setupSource?: string; rootDir: string; origin: PluginOrigin; format?: PluginFormat; @@ -355,6 +356,7 @@ function addCandidate(params: { seen: Set; idHint: string; source: string; + setupSource?: string; rootDir: string; origin: PluginOrigin; format?: PluginFormat; @@ -385,6 +387,7 @@ function addCandidate(params: { params.candidates.push({ idHint: params.idHint, source: resolved, + setupSource: params.setupSource, rootDir: resolvedRoot, origin: params.origin, format: params.format ?? "openclaw", @@ -520,6 +523,17 @@ function discoverInDirectory(params: { const manifest = readPackageManifest(fullPath, rejectHardlinks); const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; + const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry; + const setupSource = + typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0 + ? resolvePackageEntrySource({ + packageDir: fullPath, + entryPath: setupEntryPath, + sourceLabel: fullPath, + diagnostics: params.diagnostics, + rejectHardlinks, + }) + : null; if (extensions.length > 0) { for (const extPath of extensions) { @@ -543,6 +557,7 @@ function discoverInDirectory(params: { hasMultipleExtensions: extensions.length > 1, }), source: resolved, + ...(setupSource ? { setupSource } : {}), rootDir: fullPath, origin: params.origin, ownershipUid: params.ownershipUid, @@ -577,6 +592,7 @@ function discoverInDirectory(params: { seen: params.seen, idHint: entry.name, source: indexFile, + ...(setupSource ? { setupSource } : {}), rootDir: fullPath, origin: params.origin, ownershipUid: params.ownershipUid, @@ -637,6 +653,17 @@ function discoverFromPath(params: { const manifest = readPackageManifest(resolved, rejectHardlinks); const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; + const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry; + const setupSource = + typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0 + ? resolvePackageEntrySource({ + packageDir: resolved, + entryPath: setupEntryPath, + sourceLabel: resolved, + diagnostics: params.diagnostics, + rejectHardlinks, + }) + : null; if (extensions.length > 0) { for (const extPath of extensions) { @@ -660,6 +687,7 @@ function discoverFromPath(params: { hasMultipleExtensions: extensions.length > 1, }), source, + ...(setupSource ? { setupSource } : {}), rootDir: resolved, origin: params.origin, ownershipUid: params.ownershipUid, @@ -695,6 +723,7 @@ function discoverFromPath(params: { seen: params.seen, idHint: path.basename(resolved), source: indexFile, + ...(setupSource ? { setupSource } : {}), rootDir: resolved, origin: params.origin, ownershipUid: params.ownershipUid, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 0460e481b25..fb6805667cb 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1703,6 +1703,188 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s expect(disabled?.status).toBe("disabled"); }); + it("skips disabled channel imports unless setup-only loading is explicitly enabled", () => { + useNoBundledPlugins(); + const marker = path.join(makeTempDir(), "lazy-channel-imported.txt"); + const plugin = writePlugin({ + id: "lazy-channel", + filename: "lazy-channel.cjs", + body: `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "loaded", "utf-8"); +module.exports = { + id: "lazy-channel", + register(api) { + api.registerChannel({ + plugin: { + id: "lazy-channel", + meta: { + id: "lazy-channel", + label: "Lazy Channel", + selectionLabel: "Lazy Channel", + docsPath: "/channels/lazy-channel", + blurb: "lazy test channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "lazy-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["lazy-channel"], + }, + null, + 2, + ), + "utf-8", + ); + const config = { + plugins: { + load: { paths: [plugin.file] }, + allow: ["lazy-channel"], + entries: { + "lazy-channel": { enabled: false }, + }, + }, + }; + + const registry = loadOpenClawPlugins({ + cache: false, + config, + }); + + expect(fs.existsSync(marker)).toBe(false); + expect(registry.channelSetups).toHaveLength(0); + expect(registry.plugins.find((entry) => entry.id === "lazy-channel")?.status).toBe("disabled"); + + const setupRegistry = loadOpenClawPlugins({ + cache: false, + config, + includeSetupOnlyChannelPlugins: true, + }); + + expect(fs.existsSync(marker)).toBe(true); + expect(setupRegistry.channelSetups).toHaveLength(1); + expect(setupRegistry.channels).toHaveLength(0); + expect(setupRegistry.plugins.find((entry) => entry.id === "lazy-channel")?.status).toBe( + "disabled", + ); + }); + + it("uses package setupEntry for setup-only channel loads", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-entry-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-entry-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-entry-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-entry-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-entry-test", + meta: { + id: "setup-entry-test", + label: "Setup Entry Test", + selectionLabel: "Setup Entry Test", + docsPath: "/channels/setup-entry-test", + blurb: "full entry should not run in setup-only mode", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-entry-test", + meta: { + id: "setup-entry-test", + label: "Setup Entry Test", + selectionLabel: "Setup Entry Test", + docsPath: "/channels/setup-entry-test", + blurb: "setup entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const setupRegistry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-entry-test"], + entries: { + "setup-entry-test": { enabled: false }, + }, + }, + }, + includeSetupOnlyChannelPlugins: true, + }); + + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + expect(setupRegistry.channelSetups).toHaveLength(1); + expect(setupRegistry.channels).toHaveLength(0); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 13f6842d1e1..40fd3e36cfd 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; +import type { ChannelDock } from "../channels/dock.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; @@ -51,6 +53,7 @@ export type PluginLoadOptions = { cache?: boolean; mode?: "full" | "validate"; onlyPluginIds?: string[]; + includeSetupOnlyChannelPlugins?: boolean; activate?: boolean; }; @@ -244,6 +247,7 @@ function buildCacheKey(params: { installs?: Record; env: NodeJS.ProcessEnv; onlyPluginIds?: string[]; + includeSetupOnlyChannelPlugins?: boolean; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -267,11 +271,12 @@ function buildCacheKey(params: { ]), ); const scopeKey = JSON.stringify(params.onlyPluginIds ?? []); + const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ ...params.plugins, installs, loadPaths, - })}::${scopeKey}`; + })}::${scopeKey}::${setupOnlyKey}`; } function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { @@ -326,6 +331,32 @@ function resolvePluginModuleExport(moduleExport: unknown): { return {}; } +function resolveSetupChannelRegistration(moduleExport: unknown): { + plugin?: ChannelPlugin; + dock?: ChannelDock; +} { + const resolved = + moduleExport && + typeof moduleExport === "object" && + "default" in (moduleExport as Record) + ? (moduleExport as { default: unknown }).default + : moduleExport; + if (!resolved || typeof resolved !== "object") { + return {}; + } + const setup = resolved as { + plugin?: unknown; + dock?: unknown; + }; + if (!setup.plugin || typeof setup.plugin !== "object") { + return {}; + } + return { + plugin: setup.plugin as ChannelPlugin, + ...(setup.dock && typeof setup.dock === "object" ? { dock: setup.dock as ChannelDock } : {}), + }; +} + function createPluginRecord(params: { id: string; name?: string; @@ -669,6 +700,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const normalized = normalizePluginsConfig(cfg.plugins); const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; + const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; const shouldActivate = options.activate !== false; // NOTE: `activate` is intentionally excluded from the cache key. All non-activating // (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they @@ -680,6 +712,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi installs: cfg.plugins?.installs, env, onlyPluginIds, + includeSetupOnlyChannelPlugins, }); const cacheEnabled = options.cache !== false; if (cacheEnabled) { @@ -892,7 +925,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const registrationMode = enableState.enabled ? "full" - : !validateOnly && manifestRecord.channels.length > 0 + : includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0 ? "setup-only" : null; @@ -960,8 +993,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } const pluginRoot = safeRealpathOrResolve(candidate.rootDir); + const loadSource = + registrationMode === "setup-only" && manifestRecord.setupSource + ? manifestRecord.setupSource + : candidate.source; const opened = openBoundaryFileSync({ - absolutePath: candidate.source, + absolutePath: loadSource, rootPath: pluginRoot, boundaryLabel: "plugin root", rejectHardlinks: candidate.origin !== "bundled", @@ -992,6 +1029,31 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } + if (registrationMode === "setup-only" && manifestRecord.setupSource) { + const setupRegistration = resolveSetupChannelRegistration(mod); + if (setupRegistration.plugin) { + if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { + pushPluginLoadError( + `plugin id mismatch (config uses "${record.id}", setup export uses "${setupRegistration.plugin.id}")`, + ); + continue; + } + const api = createApi(record, { + config: cfg, + pluginConfig: {}, + hookPolicy: entry?.hooks, + registrationMode, + }); + api.registerChannel({ + plugin: setupRegistration.plugin, + ...(setupRegistration.dock ? { dock: setupRegistration.dock } : {}), + }); + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } + } + const resolved = resolvePluginModuleExport(mod); const definition = resolved.definition; const register = resolved.register; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 48fdae50d95..2c24b87f541 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -48,6 +48,7 @@ export type PluginManifestRecord = { workspaceDir?: string; rootDir: string; source: string; + setupSource?: string; manifestPath: string; schemaCacheKey?: string; configSchema?: Record; @@ -158,6 +159,7 @@ function buildRecord(params: { workspaceDir: params.candidate.workspaceDir, rootDir: params.candidate.rootDir, source: params.candidate.source, + setupSource: params.candidate.setupSource, manifestPath: params.manifestPath, schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 3a3abe0a620..0cbdd9264f3 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -148,6 +148,7 @@ export type PluginPackageInstall = { export type OpenClawPackageManifest = { extensions?: string[]; + setupEntry?: string; channel?: PluginPackageChannel; install?: PluginPackageInstall; }; diff --git a/tsdown.config.ts b/tsdown.config.ts index 2b7c9dbe192..b266f660421 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -124,13 +124,21 @@ function listBundledPluginBuildEntries(): Record { if (fs.existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - openclaw?: { extensions?: unknown }; + openclaw?: { extensions?: unknown; setupEntry?: unknown }; }; packageEntries = Array.isArray(packageJson.openclaw?.extensions) ? packageJson.openclaw.extensions.filter( (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, ) : []; + const setupEntry = + typeof packageJson.openclaw?.setupEntry === "string" && + packageJson.openclaw.setupEntry.trim().length > 0 + ? packageJson.openclaw.setupEntry + : undefined; + if (setupEntry) { + packageEntries = Array.from(new Set([...packageEntries, setupEntry])); + } } catch { packageEntries = []; }