perf(plugins): lazy-load setup surfaces
This commit is contained in:
parent
de6666b895
commit
fb991e6f31
@ -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.*`.
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "bluebubbles",
|
||||
"label": "BlueBubbles",
|
||||
|
||||
5
extensions/bluebubbles/setup-entry.ts
Normal file
5
extensions/bluebubbles/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { bluebubblesPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: bluebubblesPlugin,
|
||||
};
|
||||
@ -13,6 +13,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "feishu",
|
||||
"label": "Feishu",
|
||||
|
||||
5
extensions/feishu/setup-entry.ts
Normal file
5
extensions/feishu/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { feishuPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: feishuPlugin,
|
||||
};
|
||||
@ -19,6 +19,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "googlechat",
|
||||
"label": "Google Chat",
|
||||
|
||||
6
extensions/googlechat/setup-entry.ts
Normal file
6
extensions/googlechat/setup-entry.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { googlechatDock, googlechatPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: googlechatPlugin,
|
||||
dock: googlechatDock,
|
||||
};
|
||||
@ -9,6 +9,7 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts"
|
||||
}
|
||||
}
|
||||
|
||||
5
extensions/irc/setup-entry.ts
Normal file
5
extensions/irc/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { ircPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: ircPlugin,
|
||||
};
|
||||
@ -15,6 +15,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "matrix",
|
||||
"label": "Matrix",
|
||||
|
||||
5
extensions/matrix/setup-entry.ts
Normal file
5
extensions/matrix/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: matrixPlugin,
|
||||
};
|
||||
@ -11,6 +11,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "msteams",
|
||||
"label": "Microsoft Teams",
|
||||
|
||||
5
extensions/msteams/setup-entry.ts
Normal file
5
extensions/msteams/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { msteamsPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: msteamsPlugin,
|
||||
};
|
||||
@ -10,6 +10,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "nextcloud-talk",
|
||||
"label": "Nextcloud Talk",
|
||||
|
||||
5
extensions/nextcloud-talk/setup-entry.ts
Normal file
5
extensions/nextcloud-talk/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { nextcloudTalkPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: nextcloudTalkPlugin,
|
||||
};
|
||||
@ -13,6 +13,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "tlon",
|
||||
"label": "Tlon",
|
||||
|
||||
5
extensions/tlon/setup-entry.ts
Normal file
5
extensions/tlon/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { tlonPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: tlonPlugin,
|
||||
};
|
||||
@ -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) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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) });
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -250,6 +250,7 @@ function loadOnboardingPluginRegistry(params: {
|
||||
cache: false,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
onlyPluginIds: params.onlyPluginIds,
|
||||
includeSetupOnlyChannelPlugins: true,
|
||||
activate: params.activate,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -148,6 +148,7 @@ export type PluginPackageInstall = {
|
||||
|
||||
export type OpenClawPackageManifest = {
|
||||
extensions?: string[];
|
||||
setupEntry?: string;
|
||||
channel?: PluginPackageChannel;
|
||||
install?: PluginPackageInstall;
|
||||
};
|
||||
|
||||
@ -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 = [];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user