perf(plugins): lazy-load setup surfaces

This commit is contained in:
Peter Steinberger 2026-03-15 18:46:22 -07:00
parent de6666b895
commit fb991e6f31
No known key found for this signature in database
30 changed files with 443 additions and 16 deletions

View File

@ -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 <npm-spec>` uses `npm pack`, extracts into `~/.openclaw/extensions/<id>/`, and enables it in config.
- Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`.

View File

@ -10,6 +10,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "bluebubbles",
"label": "BlueBubbles",

View File

@ -0,0 +1,5 @@
import { bluebubblesPlugin } from "./src/channel.js";
export default {
plugin: bluebubblesPlugin,
};

View File

@ -13,6 +13,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "feishu",
"label": "Feishu",

View File

@ -0,0 +1,5 @@
import { feishuPlugin } from "./src/channel.js";
export default {
plugin: feishuPlugin,
};

View File

@ -19,6 +19,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "googlechat",
"label": "Google Chat",

View File

@ -0,0 +1,6 @@
import { googlechatDock, googlechatPlugin } from "./src/channel.js";
export default {
plugin: googlechatPlugin,
dock: googlechatDock,
};

View File

@ -9,6 +9,7 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"setupEntry": "./setup-entry.ts"
}
}

View File

@ -0,0 +1,5 @@
import { ircPlugin } from "./src/channel.js";
export default {
plugin: ircPlugin,
};

View File

@ -15,6 +15,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "matrix",
"label": "Matrix",

View File

@ -0,0 +1,5 @@
import { matrixPlugin } from "./src/channel.js";
export default {
plugin: matrixPlugin,
};

View File

@ -11,6 +11,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "msteams",
"label": "Microsoft Teams",

View File

@ -0,0 +1,5 @@
import { msteamsPlugin } from "./src/channel.js";
export default {
plugin: msteamsPlugin,
};

View File

@ -10,6 +10,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "nextcloud-talk",
"label": "Nextcloud Talk",

View File

@ -0,0 +1,5 @@
import { nextcloudTalkPlugin } from "./src/channel.js";
export default {
plugin: nextcloudTalkPlugin,
};

View File

@ -13,6 +13,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "tlon",
"label": "Tlon",

View File

@ -0,0 +1,5 @@
import { tlonPlugin } from "./src/channel.js";
export default {
plugin: tlonPlugin,
};

View File

@ -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) }
: {}),
};
}

View File

@ -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"],

View File

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

View File

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

View File

@ -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<typeof listChannelSetupPlugins>;
}): Promise<ChannelStatusSummary> {
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);
}

View File

@ -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,
}),
);

View File

@ -250,6 +250,7 @@ function loadOnboardingPluginRegistry(params: {
cache: false,
logger: createPluginLoaderLogger(log),
onlyPluginIds: params.onlyPluginIds,
includeSetupOnlyChannelPlugins: true,
activate: params.activate,
});
}

View File

@ -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<string>;
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,

View File

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

View File

@ -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<string, PluginInstallRecord>;
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<string, unknown>)
? (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;

View File

@ -48,6 +48,7 @@ export type PluginManifestRecord = {
workspaceDir?: string;
rootDir: string;
source: string;
setupSource?: string;
manifestPath: string;
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
@ -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,

View File

@ -148,6 +148,7 @@ export type PluginPackageInstall = {
export type OpenClawPackageManifest = {
extensions?: string[];
setupEntry?: string;
channel?: PluginPackageChannel;
install?: PluginPackageInstall;
};

View File

@ -124,13 +124,21 @@ function listBundledPluginBuildEntries(): Record<string, string> {
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 = [];
}