refactor(plugins): reuse scoped loader for capability discovery (#117373)

* refactor(plugins): reuse scoped loader for capabilities

* fix(plugins): retain channel capabilities during discovery
This commit is contained in:
Peter Steinberger
2026-08-01 09:50:34 -07:00
committed by GitHub
parent ea2e7f46a6
commit fdec6fe7d4
17 changed files with 428 additions and 671 deletions
@@ -25,9 +25,9 @@ ad60ccc4fe9084d47f0477e02d9296bacad32f26d7456e2a84be8d25a53a25c2 module/boolean
16dc9d32e8ca3ef78fc63e0fc4b20e6b943633e9572de1a49d24a880b6ffc66c module/channel-config-primitives
c0f910ebfa3dbf283145fb1e3b9c016d03e853ef13e70402b09f3b9d9c2f4ab0 module/channel-config-schema
2dd98659d9600e755f09ef00dd91c36692b8562ed3700461937e94f6cd1e640a module/channel-contract
23fba2ab304d27b33fb8923e542741a03dd93b5b60cffc0337d89c46090ace95 module/channel-core
6e7bb72d2fe65cd2541af6c057dba4faa5079f410f34e08fcb5348ab38e7a666 module/channel-core
9a5aaf650f9242523bb57bdc2556c323ab64e55aa11e25e2685b73b23ee12534 module/channel-dm-policy
fbf353eb38ae68d8ded3f2a60b432c7bb2c245d2ec7e7c9f53c6da19a0db0938 module/channel-entry-contract
ba41c40956d6b4565605fa38c2d12f4b8471a0f8afe842798716b9032ee4d74a module/channel-entry-contract
982f29a18e07228e3da82cae67d06ff38249592a29c2fd28f01f0d2016ff80d9 module/channel-feedback
d645d24bcb7a5f68cc46c692ad0d1fbd19be0a99996f31e9479ce9cffce301c1 module/channel-inbound
76bb7f531f3702c801e8fe7479e9e499f601fb361a4303afdcb45fc0da440e4b module/channel-inbound-debounce
@@ -58,7 +58,7 @@ eb4c757fe0086c1dbfa4c3f3caf3dcff0d3cab3924c608237f08f740a6ee5f59 module/command
20f3f8042de53e4eee61b64de9102c8c202b9299e6a29235647a4729f70145f2 module/config-mutation
189fa5a240cad0404cd281ad0a14a105a8f3231278d87b71cfbc4f96fb8e48ef module/config-runtime
c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/conversation-runtime
8994adeb5aec2799c1a6d492f4f69db38ab15381038525666463580f520c5cfc module/core
dea96213010cbc816345b1229534f1acb8b0bcfdbd7814c62eefc77030fa9cd8 module/core
4af19d59c2f18674e7d7f7dc1b358b644dc707e6bd601dc47168bd9e4a669940 module/dedupe-runtime
f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-bootstrap
6215d3af5923bf5a616d73062534968b69f448e3e30adc64ae9caebdd1a46d71 module/diagnostic-runtime
+31 -23
View File
@@ -170,7 +170,8 @@ export default definePluginEntry({
Wraps `definePluginEntry` with channel-specific wiring: it automatically
calls `api.registerChannel({ plugin })`, exposes an optional root-help CLI
metadata seam, and gates `registerFull` on registration mode.
metadata seam, and gates capability and full-runtime callbacks on registration
mode.
```typescript
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
@@ -187,19 +188,23 @@ export default defineChannelPluginEntry({
registerFull(api) {
api.registerGatewayMethod(/* ... */);
},
registerCapabilities(api) {
api.registerTranscriptSourceProvider(/* ... */);
},
});
```
| Field | Type | Required | Default |
| --------------------- | ---------------------------------------------------------------- | -------- | ------------------- |
| `id` | `string` | Yes | - |
| `name` | `string` | Yes | - |
| `description` | `string` | Yes | - |
| `plugin` | `ChannelPlugin` | Yes | - |
| `configSchema` | `OpenClawPluginConfigSchema \| () => OpenClawPluginConfigSchema` | No | Empty object schema |
| `setRuntime` | `(runtime: PluginRuntime) => void` | No | - |
| `registerCliMetadata` | `(api: OpenClawPluginApi) => void` | No | - |
| `registerFull` | `(api: OpenClawPluginApi) => void` | No | - |
| Field | Type | Required | Default |
| ---------------------- | ---------------------------------------------------------------- | -------- | ------------------- |
| `id` | `string` | Yes | - |
| `name` | `string` | Yes | - |
| `description` | `string` | Yes | - |
| `plugin` | `ChannelPlugin` | Yes | - |
| `configSchema` | `OpenClawPluginConfigSchema \| () => OpenClawPluginConfigSchema` | No | Empty object schema |
| `setRuntime` | `(runtime: PluginRuntime) => void` | No | - |
| `registerCliMetadata` | `(api: OpenClawPluginApi) => void` | No | - |
| `registerFull` | `(api: OpenClawPluginApi) => void` | No | - |
| `registerCapabilities` | `(api: OpenClawPluginApi) => void` | No | - |
Callbacks run per registration mode (full table under
[Registration mode](#registration-mode)):
@@ -214,10 +219,13 @@ Callbacks run per registration mode (full table under
plugin loads.
- `registerFull` runs only for `"full"` and `"tool-discovery"`. For
`"tool-discovery"` it runs _instead of_ channel registration: OpenClaw
skips `registerChannel`/`setRuntime` entirely and calls only
`registerFull`, so any provider/tool registration your channel needs for
standalone tool discovery or execution must live there, not behind normal
channel setup.
skips `registerChannel`/`setRuntime` entirely and calls the full-runtime
callback followed by the capability callback. Keep tool registration in
`registerFull` and capability providers in `registerCapabilities`.
- `registerCapabilities` runs for `"discovery"`, `"full"`, and
`"tool-discovery"`. Register inert advertised providers here so read-only
capability discovery can find them without starting sockets, clients,
workers, or services.
- Discovery registration is non-activating, not import-free: OpenClaw may
evaluate the trusted plugin entry and channel plugin module to build the
snapshot. Keep top-level imports side-effect-free and put sockets,
@@ -327,14 +335,14 @@ full activation.
`api.registrationMode` tells your plugin how it was loaded:
| Mode | When | What to register |
| ------------------ | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `"full"` | Normal gateway startup | Everything |
| `"discovery"` | Read-only capability discovery | Channel registration plus static CLI descriptors; entry code may load, but skip sockets, workers, clients, and services |
| `"tool-discovery"` | Scoped load to list or run specific plugins' tools | Capability/tool registration only; no channel activation |
| `"setup-only"` | Disabled/unconfigured channel | Channel registration only |
| `"setup-runtime"` | Setup flow with runtime available | Channel registration plus only the lightweight runtime needed before the full entry loads |
| `"cli-metadata"` | Root help / CLI metadata capture | CLI descriptors only |
| Mode | When | What to register |
| ------------------ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `"full"` | Normal gateway startup | Everything |
| `"discovery"` | Read-only capability discovery | Channel registration, static CLI descriptors, and inert providers; skip sockets, workers, clients, and services |
| `"tool-discovery"` | Scoped load to list or run specific plugins' tools | Capability/tool registration only; no channel activation |
| `"setup-only"` | Disabled/unconfigured channel | Channel registration only |
| `"setup-runtime"` | Setup flow with runtime available | Channel registration plus only the lightweight runtime needed before the full entry loads |
| `"cli-metadata"` | Root help / CLI metadata capture | CLI descriptors only |
`defineChannelPluginEntry` handles this split automatically. If you use
`definePluginEntry` directly for a channel, check mode yourself and remember
+2
View File
@@ -24,6 +24,8 @@ export default defineBundledChannelEntry({
registerFull(api) {
registerDiscordActivities(api);
registerDiscordSubagentHooks(api);
},
registerCapabilities(api) {
api.registerTranscriptSourceProvider(discordVoiceTranscriptsSourceProvider);
},
});
@@ -104,6 +104,7 @@ function createBundledChannelEntry(params: {
pluginId: string;
registerCliMetadata?: (api: OpenClawPluginApi) => void;
registerFull?: (api: OpenClawPluginApi) => void;
registerCapabilities?: (api: OpenClawPluginApi) => void;
}) {
return defineBundledChannelEntry({
id: params.pluginId,
@@ -114,6 +115,7 @@ function createBundledChannelEntry(params: {
runtime: { specifier: "./runtime.cjs", exportName: "setRuntime" },
registerCliMetadata: params.registerCliMetadata,
registerFull: params.registerFull,
registerCapabilities: params.registerCapabilities,
});
}
@@ -128,6 +130,7 @@ describe("defineBundledChannelEntry", () => {
runtimeMarker,
});
const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>();
const registerCapabilities = vi.fn<(api: OpenClawPluginApi) => void>();
const registerFull = vi.fn<(api: OpenClawPluginApi) => void>((api) => {
api.registerTool(
{
@@ -145,6 +148,7 @@ describe("defineBundledChannelEntry", () => {
pluginId,
registerCliMetadata,
registerFull,
registerCapabilities,
});
const api = createApi("tool-discovery");
@@ -153,6 +157,7 @@ describe("defineBundledChannelEntry", () => {
expect(api.registerChannel).not.toHaveBeenCalled();
expect(registerCliMetadata).not.toHaveBeenCalled();
expect(registerFull).toHaveBeenCalledWith(api);
expect(registerCapabilities).toHaveBeenCalledExactlyOnceWith(api);
expect(api.registerTool).toHaveBeenCalledTimes(1);
expect(fs.existsSync(runtimeMarker)).toBe(false);
});
@@ -168,11 +173,13 @@ describe("defineBundledChannelEntry", () => {
});
const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>();
const registerFull = vi.fn<(api: OpenClawPluginApi) => void>();
const registerCapabilities = vi.fn<(api: OpenClawPluginApi) => void>();
const entry = createBundledChannelEntry({
importerPath,
pluginId,
registerCliMetadata,
registerFull,
registerCapabilities,
});
const api = createApi("discovery");
@@ -181,6 +188,7 @@ describe("defineBundledChannelEntry", () => {
expect(api.registerChannel).toHaveBeenCalledTimes(1);
expect(registerCliMetadata).toHaveBeenCalledWith(api);
expect(registerFull).not.toHaveBeenCalled();
expect(registerCapabilities).toHaveBeenCalledExactlyOnceWith(api);
expect(fs.existsSync(runtimeMarker)).toBe(true);
});
@@ -195,17 +203,31 @@ describe("defineBundledChannelEntry", () => {
});
const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>();
const registerFull = vi.fn<(api: OpenClawPluginApi) => void>();
const registerCapabilities = vi.fn<(api: OpenClawPluginApi) => void>();
const entry = createBundledChannelEntry({
importerPath,
pluginId,
registerCliMetadata,
registerFull,
registerCapabilities,
});
const cliApi = createApi("cli-metadata");
entry.register(cliApi);
expect(registerCliMetadata).toHaveBeenCalledWith(cliApi);
expect(registerCapabilities).not.toHaveBeenCalled();
expect(fs.existsSync(runtimeMarker)).toBe(false);
registerCliMetadata.mockClear();
entry.register(createApi("setup-only"));
expect(registerCapabilities).not.toHaveBeenCalled();
fs.rmSync(runtimeMarker, { force: true });
entry.register(createApi("setup-runtime"));
expect(fs.existsSync(runtimeMarker)).toBe(true);
expect(registerCliMetadata).not.toHaveBeenCalled();
expect(registerFull).not.toHaveBeenCalled();
expect(registerCapabilities).not.toHaveBeenCalled();
fs.rmSync(runtimeMarker, { force: true });
const fullApi = createApi("full");
@@ -213,6 +235,7 @@ describe("defineBundledChannelEntry", () => {
expect(fs.existsSync(runtimeMarker)).toBe(true);
expect(registerCliMetadata).toHaveBeenCalledWith(fullApi);
expect(registerFull).toHaveBeenCalledWith(fullApi);
expect(registerCapabilities).toHaveBeenCalledExactlyOnceWith(fullApi);
});
});
+5
View File
@@ -63,6 +63,7 @@ type DefineBundledChannelEntryOptions<TPlugin = ChannelPlugin> = {
features?: BundledChannelEntryFeatures;
registerCliMetadata?: (api: OpenClawPluginApi) => void;
registerFull?: (api: OpenClawPluginApi) => void;
registerCapabilities?: (api: OpenClawPluginApi) => void;
};
type DefineBundledChannelSetupEntryOptions = {
@@ -482,6 +483,7 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
features,
registerCliMetadata,
registerFull,
registerCapabilities,
}: DefineBundledChannelEntryOptions<TPlugin>): BundledChannelEntryContract<TPlugin> {
const resolvedConfigSchema: ChannelEntryConfigSchema<TPlugin> =
typeof configSchema === "function"
@@ -540,6 +542,7 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
if (api.registrationMode === "tool-discovery") {
const profile = createProfiler({ pluginId: id, source: importMetaUrl });
profile("bundled-register:registerFull", () => registerFull?.(api));
profile("bundled-register:registerCapabilities", () => registerCapabilities?.(api));
return;
}
const profile = createProfiler({ pluginId: id, source: importMetaUrl });
@@ -550,6 +553,7 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
profile("bundled-register:setChannelRuntime", () => setChannelRuntime?.(api.runtime));
if (api.registrationMode === "discovery") {
profile("bundled-register:registerCliMetadata", () => registerCliMetadata?.(api));
profile("bundled-register:registerCapabilities", () => registerCapabilities?.(api));
return;
}
if (api.registrationMode !== "full") {
@@ -557,6 +561,7 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
}
profile("bundled-register:registerCliMetadata", () => registerCliMetadata?.(api));
profile("bundled-register:registerFull", () => registerFull?.(api));
profile("bundled-register:registerCapabilities", () => registerCapabilities?.(api));
},
loadChannelPlugin,
...(loadChannelOutbound ? { loadChannelOutbound } : {}),
+19
View File
@@ -43,6 +43,7 @@ describe("defineChannelPluginEntry", () => {
it("runs tool registrations without channel runtime wiring during tool discovery", () => {
const setRuntime = vi.fn<(runtime: PluginRuntime) => void>();
const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>();
const registerCapabilities = vi.fn<(api: OpenClawPluginApi) => void>();
const registerFull = vi.fn<(api: OpenClawPluginApi) => void>((api) => {
api.registerTool(
{
@@ -63,6 +64,7 @@ describe("defineChannelPluginEntry", () => {
setRuntime,
registerCliMetadata,
registerFull,
registerCapabilities,
});
const api = createApi("tool-discovery");
@@ -72,6 +74,7 @@ describe("defineChannelPluginEntry", () => {
expect(setRuntime).not.toHaveBeenCalled();
expect(registerCliMetadata).not.toHaveBeenCalled();
expect(registerFull).toHaveBeenCalledWith(api);
expect(registerCapabilities).toHaveBeenCalledExactlyOnceWith(api);
expect(api.registerTool).toHaveBeenCalledTimes(1);
});
@@ -79,6 +82,7 @@ describe("defineChannelPluginEntry", () => {
const setRuntime = vi.fn<(runtime: PluginRuntime) => void>();
const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>();
const registerFull = vi.fn<(api: OpenClawPluginApi) => void>();
const registerCapabilities = vi.fn<(api: OpenClawPluginApi) => void>();
const entry = defineChannelPluginEntry({
id: "runtime-discovery",
name: "Runtime Discovery",
@@ -87,6 +91,7 @@ describe("defineChannelPluginEntry", () => {
setRuntime,
registerCliMetadata,
registerFull,
registerCapabilities,
});
const api = createApi("discovery");
@@ -96,12 +101,14 @@ describe("defineChannelPluginEntry", () => {
expect(registerCliMetadata).toHaveBeenCalledTimes(1);
expect(setRuntime).toHaveBeenCalledWith(api.runtime);
expect(registerFull).not.toHaveBeenCalled();
expect(registerCapabilities).toHaveBeenCalledExactlyOnceWith(api);
});
it("keeps setup-runtime and full registration wired to runtime helpers", () => {
const setRuntime = vi.fn<(runtime: PluginRuntime) => void>();
const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>();
const registerFull = vi.fn<(api: OpenClawPluginApi) => void>();
const registerCapabilities = vi.fn<(api: OpenClawPluginApi) => void>();
const entry = defineChannelPluginEntry({
id: "runtime-activation",
name: "Runtime Activation",
@@ -110,13 +117,24 @@ describe("defineChannelPluginEntry", () => {
setRuntime,
registerCliMetadata,
registerFull,
registerCapabilities,
});
const cliApi = createApi("cli-metadata");
entry.register(cliApi);
expect(registerCliMetadata).toHaveBeenCalledWith(cliApi);
expect(registerCapabilities).not.toHaveBeenCalled();
registerCliMetadata.mockClear();
entry.register(createApi("setup-only"));
expect(registerCapabilities).not.toHaveBeenCalled();
const setupApi = createApi("setup-runtime");
entry.register(setupApi);
expect(setRuntime).toHaveBeenCalledWith(setupApi.runtime);
expect(registerCliMetadata).not.toHaveBeenCalled();
expect(registerFull).not.toHaveBeenCalled();
expect(registerCapabilities).not.toHaveBeenCalled();
setRuntime.mockClear();
const fullApi = createApi("full");
@@ -124,6 +142,7 @@ describe("defineChannelPluginEntry", () => {
expect(setRuntime).toHaveBeenCalledWith(fullApi.runtime);
expect(registerCliMetadata).toHaveBeenCalledWith(fullApi);
expect(registerFull).toHaveBeenCalledWith(fullApi);
expect(registerCapabilities).toHaveBeenCalledExactlyOnceWith(fullApi);
});
});
+5
View File
@@ -502,6 +502,7 @@ type DefineChannelPluginEntryOptions<TPlugin = ChannelPlugin> = {
setRuntime?: (runtime: PluginRuntime) => void;
registerCliMetadata?: (api: OpenClawPluginApi) => void;
registerFull?: (api: OpenClawPluginApi) => void;
registerCapabilities?: (api: OpenClawPluginApi) => void;
};
type DefinedChannelPluginEntry<TPlugin> = {
@@ -575,6 +576,7 @@ export function defineChannelPluginEntry<TPlugin>({
setRuntime,
registerCliMetadata,
registerFull,
registerCapabilities,
}: DefineChannelPluginEntryOptions<TPlugin>): DefinedChannelPluginEntry<TPlugin> {
const resolvedConfigSchema: ChannelEntryConfigSchema<TPlugin> =
typeof configSchema === "function"
@@ -592,12 +594,14 @@ export function defineChannelPluginEntry<TPlugin>({
}
if (api.registrationMode === "tool-discovery") {
registerFull?.(api);
registerCapabilities?.(api);
return;
}
api.registerChannel({ plugin: plugin as ChannelPlugin });
setRuntime?.(api.runtime);
if (api.registrationMode === "discovery") {
registerCliMetadata?.(api);
registerCapabilities?.(api);
return;
}
if (api.registrationMode !== "full") {
@@ -605,6 +609,7 @@ export function defineChannelPluginEntry<TPlugin>({
}
registerCliMetadata?.(api);
registerFull?.(api);
registerCapabilities?.(api);
},
};
return {
@@ -0,0 +1,187 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import type { PluginDiscoveryResult } from "./discovery.js";
import { loadOpenClawPlugins } from "./loader.js";
import {
cleanupPluginLoaderFixturesForTest,
resetPluginLoaderTestStateForTest,
type TempPlugin,
writePlugin,
} from "./loader.test-fixtures.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import {
captureActivePluginRegistrySnapshot,
collectLivePluginRegistries,
getActivePluginRegistry,
getPluginRegistrationContext,
listImportedRuntimePluginIds,
setActivePluginRegistry,
} from "./runtime.js";
afterEach(resetPluginLoaderTestStateForTest);
afterAll(cleanupPluginLoaderFixturesForTest);
function discoveryFor(...plugins: TempPlugin[]): PluginDiscoveryResult {
return {
candidates: plugins.map((plugin) => ({
idHint: plugin.id,
rootDir: plugin.dir,
source: plugin.file,
origin: "bundled",
})),
diagnostics: [],
};
}
function writeArtifactPreferencePlugin(id: string): TempPlugin {
const plugin = writePlugin({
id,
filename: "index.ts",
body: `export default {
id: ${JSON.stringify(id)},
register(api) {
api.registerProvider({ id: ${JSON.stringify(`${id}-source`)}, label: "Source", auth: [] });
},
};`,
});
const distDir = path.join(plugin.dir, "dist");
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(
path.join(distDir, "index.js"),
`module.exports = {
id: ${JSON.stringify(id)},
register(api) {
api.registerProvider({ id: ${JSON.stringify(`${id}-built`)}, label: "Built", auth: [] });
},
};`,
);
return plugin;
}
function loadCanonicalFixture(plugin: TempPlugin, discovery: PluginDiscoveryResult) {
return loadOpenClawPlugins({
config: {
plugins: {
allow: [plugin.id],
entries: { [plugin.id]: { enabled: true } },
},
},
discovery,
onlyPluginIds: [plugin.id],
preferBuiltPluginArtifacts: false,
cache: false,
activate: false,
});
}
describe("loadBundledCapabilityRuntimeRegistry", () => {
it("loads only the requested bundled plugin without replacing the active registry", () => {
const target = writePlugin({
id: "capability-target",
body: `module.exports = {
id: "capability-target",
register(api) {
if (api.registrationMode === "discovery") {
api.registerProvider({ id: "capability-target", label: "Target", auth: [] });
}
if (api.registrationMode === "full") {
api.registerProvider({ id: "full-only", label: "Full only", auth: [] });
}
},
};`,
});
const unscoped = writePlugin({
id: "capability-unscoped",
body: `module.exports = {
id: "capability-unscoped",
register() { throw new Error("unscoped plugin loaded"); },
};`,
});
const active = createEmptyPluginRegistry();
setActivePluginRegistry(active, "existing-registry");
const activeSnapshotBefore = captureActivePluginRegistrySnapshot();
const liveRegistriesBefore = collectLivePluginRegistries();
const registrationContextBefore = getPluginRegistrationContext();
const registry = loadBundledCapabilityRuntimeRegistry({
pluginIds: [target.id],
discovery: discoveryFor(target, unscoped),
});
expect(registry.plugins.map((plugin) => plugin.id)).toEqual([target.id]);
expect(registry.plugins[0]?.status).toBe("loaded");
expect(registry.providers.map((entry) => entry.provider.id)).toEqual([target.id]);
expect(getActivePluginRegistry()).toBe(active);
expect(captureActivePluginRegistrySnapshot()).toEqual(activeSnapshotBefore);
expect(collectLivePluginRegistries()).toEqual(liveRegistriesBefore);
expect(getPluginRegistrationContext()).toBe(registrationContextBefore);
expect(listImportedRuntimePluginIds()).toContain(target.id);
});
it.each(["source", "built"] as const)(
"keeps %s-first artifact preferences isolated across registry snapshots",
(firstArtifact) => {
const plugin = writeArtifactPreferencePlugin(`capability-${firstArtifact}-first`);
const discovery = discoveryFor(plugin);
const loadCapability = () =>
loadBundledCapabilityRuntimeRegistry({
pluginIds: [plugin.id],
pluginSdkResolution: "dist",
discovery,
});
const first =
firstArtifact === "source" ? loadCanonicalFixture(plugin, discovery) : loadCapability();
const second =
firstArtifact === "source" ? loadCapability() : loadCanonicalFixture(plugin, discovery);
expect(first.providers.map((entry) => entry.provider.id)).toEqual([
`${plugin.id}-${firstArtifact}`,
]);
expect(second.providers.map((entry) => entry.provider.id)).toEqual([
`${plugin.id}-${firstArtifact === "source" ? "built" : "source"}`,
]);
expect(first.pluginRuntimeArtifacts).not.toBe(second.pluginRuntimeArtifacts);
},
);
it("loads runtime-backed bundled capabilities through the dist SDK shims", () => {
const registry = loadBundledCapabilityRuntimeRegistry({
pluginIds: ["codex"],
pluginSdkResolution: "dist",
});
const plugin = registry.plugins.find((entry) => entry.id === "codex");
expect(
plugin?.status,
JSON.stringify({ plugin, diagnostics: registry.diagnostics }, null, 2),
).toBe("loaded");
expect(registry.mediaUnderstandingProviders.map((entry) => entry.provider.id)).toEqual([
"codex",
]);
expect(registry.webSearchProviders.map((entry) => entry.provider.id)).toEqual(["codex"]);
expect(registry.migrationProviders.map((entry) => entry.provider.id)).toEqual(["codex"]);
});
it("registers Discord voice transcript capabilities without full channel activation", () => {
const active = createEmptyPluginRegistry();
setActivePluginRegistry(active, "existing-discord-registry");
const registry = loadBundledCapabilityRuntimeRegistry({
pluginIds: ["discord"],
pluginSdkResolution: "dist",
});
const plugin = registry.plugins.find((entry) => entry.id === "discord");
expect(
plugin?.status,
JSON.stringify({ plugin, diagnostics: registry.diagnostics }, null, 2),
).toBe("loaded");
expect(plugin?.transcriptSourceProviderIds).toEqual(["discord-voice"]);
expect(registry.transcriptSourceProviders.map((entry) => entry.provider.id)).toEqual([
"discord-voice",
]);
expect(registry.typedHooks).toEqual([]);
expect(getActivePluginRegistry()).toBe(active);
});
});
+68 -473
View File
@@ -1,36 +1,16 @@
/** Loads capability providers from bundled plugin public runtime artifacts. */
import fs from "node:fs";
/** Loads capability providers through the canonical scoped plugin loader. */
import { fileURLToPath } from "node:url";
import { describeRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { resolveBundledPluginRepoEntryPath } from "./bundled-plugin-metadata.js";
import { createCapturedPluginRegistration } from "./captured-registration.js";
import { resolveOpenClawDevSourceRoot } from "./dev-source-root.js";
import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js";
import { loadOpenClawPluginsWithInternalOverrides } from "./loader-runtime-load.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { unwrapDefaultModuleExport } from "./module-export.js";
import {
createPluginModuleLoaderCache,
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import type { PluginRecord, PluginRegistry } from "./registry.js";
import {
buildPluginLoaderAliasMap,
shouldPreferNativeModuleLoad,
type PluginSdkResolutionPreference,
} from "./sdk-alias.js";
import {
findUndeclaredPluginToolNames,
normalizePluginToolContractNames,
} from "./tool-contracts.js";
import type { OpenClawPluginDefinition, OpenClawPluginModule } from "./types.js";
import type { PluginRuntime } from "./runtime/types.js";
import type { PluginSdkResolutionPreference } from "./sdk-alias.js";
const log = createSubsystemLogger("plugins");
@@ -65,132 +45,39 @@ function buildVitestCapabilityShimAliasMap(): Record<string, string> {
);
}
function applyVitestCapabilityAliasOverrides(params: {
aliasMap: Record<string, string>;
pluginSdkResolution?: PluginSdkResolutionPreference;
env?: PluginLoadOptions["env"];
}): Record<string, string> {
if (!params.env?.VITEST || params.pluginSdkResolution !== "dist") {
return params.aliasMap;
}
return {
...params.aliasMap,
// Capability contract loads only need a narrow SDK slice. Keep those
// helpers on a tiny source graph so Vitest does not pull the dist chunk
// bundle that also drags Matrix/WhatsApp code into these tests.
...buildVitestCapabilityShimAliasMap(),
};
}
function shouldApplyVitestCapabilityAliasOverrides(params: {
pluginSdkResolution?: PluginSdkResolutionPreference;
env?: PluginLoadOptions["env"];
}): boolean {
return Boolean(params.env?.VITEST && params.pluginSdkResolution === "dist");
}
function buildBundledCapabilityRuntimeConfig(
pluginIds: readonly string[],
env?: PluginLoadOptions["env"],
): PluginLoadOptions["config"] {
): NonNullable<PluginLoadOptions["config"]> {
const enablementCompat = withBundledPluginEnablementCompat({
config: undefined,
pluginIds,
});
return withBundledPluginVitestCompat({
config: enablementCompat,
pluginIds,
env,
});
return (
withBundledPluginVitestCompat({
config: enablementCompat,
pluginIds,
env,
}) ?? {}
);
}
function resolvePluginModuleExport(moduleExport: unknown): {
definition?: OpenClawPluginDefinition;
register?: OpenClawPluginDefinition["register"];
} {
const resolved = unwrapDefaultModuleExport(moduleExport);
if (typeof resolved === "function") {
return {
register: resolved as OpenClawPluginDefinition["register"],
};
}
if (resolved && typeof resolved === "object") {
const definition = resolved as OpenClawPluginDefinition;
return {
definition,
register: definition.register,
};
}
return {};
}
function createCapabilityPluginRecord(params: {
id: string;
name?: string;
description?: string;
version?: string;
source: string;
rootDir?: string;
workspaceDir?: string;
}): PluginRecord {
function createCapabilityRegistrationRuntime(
config: NonNullable<PluginLoadOptions["config"]>,
): Pick<PluginRuntime, "config"> {
return {
id: params.id,
name: params.name ?? params.id,
version: params.version,
description: params.description,
source: params.source,
rootDir: params.rootDir,
origin: "bundled",
workspaceDir: params.workspaceDir,
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
cliBackendIds: [],
providerIds: [],
embeddingProviderIds: [],
speechProviderIds: [],
realtimeTranscriptionProviderIds: [],
realtimeVoiceProviderIds: [],
mediaUnderstandingProviderIds: [],
transcriptSourceProviderIds: [],
imageGenerationProviderIds: [],
videoGenerationProviderIds: [],
musicGenerationProviderIds: [],
webFetchProviderIds: [],
webSearchProviderIds: [],
migrationProviderIds: [],
memoryEmbeddingProviderIds: [],
agentHarnessIds: [],
cliCommands: [],
services: [],
gatewayDiscoveryServiceIds: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: true,
config: {
current: () => config,
mutateConfigFile: async () => {
throw new Error("Capability discovery cannot mutate plugin configuration.");
},
replaceConfigFile: async () => {
throw new Error("Capability discovery cannot replace plugin configuration.");
},
},
};
}
function recordCapabilityLoadError(
registry: PluginRegistry,
record: PluginRecord,
message: string,
): void {
record.status = "error";
record.error = message;
registry.plugins.push(record);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: `failed to load plugin: ${message}`,
});
log.error(`[plugins] ${record.id} failed to load from ${record.source}: ${message}`);
}
export function loadBundledCapabilityRuntimeRegistry(params: {
pluginIds: readonly string[];
env?: PluginLoadOptions["env"];
@@ -198,348 +85,56 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
discovery?: PluginDiscoveryResult;
}) {
const env = params.env ?? process.env;
const devSourceRoot = resolveOpenClawDevSourceRoot(env);
const pluginIds = new Set(params.pluginIds);
const registry = createEmptyPluginRegistry();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
const getModuleLoader = (modulePath: string) => {
const tryNative =
shouldPreferNativeModuleLoad(modulePath) &&
!(env?.VITEST && params.pluginSdkResolution === "dist");
const aliasMap = shouldApplyVitestCapabilityAliasOverrides({
pluginSdkResolution: params.pluginSdkResolution,
env,
})
? applyVitestCapabilityAliasOverrides({
aliasMap: buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
import.meta.url,
params.pluginSdkResolution,
devSourceRoot,
),
pluginSdkResolution: params.pluginSdkResolution,
env,
})
: undefined;
return getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
devSourceRoot,
loaderFilename: import.meta.url,
...(aliasMap ? { aliasMap } : {}),
pluginSdkResolution: params.pluginSdkResolution,
tryNative,
});
};
const config = buildBundledCapabilityRuntimeConfig(params.pluginIds, env);
const discovery = params.discovery ?? discoverOpenClawPlugins({ env });
const pluginIds = new Set(params.pluginIds);
const manifestRegistry = loadPluginManifestRegistry({
config: buildBundledCapabilityRuntimeConfig(params.pluginIds, env),
config,
env,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
registry.diagnostics.push(...manifestRegistry.diagnostics);
const scopedManifestRegistry = {
plugins: manifestRegistry.plugins.filter(
(plugin) => plugin.origin === "bundled" && pluginIds.has(plugin.id),
),
diagnostics: manifestRegistry.diagnostics,
};
const useVitestShims = Boolean(env.VITEST && params.pluginSdkResolution === "dist");
const manifestByRoot = new Map(
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
return loadOpenClawPluginsWithInternalOverrides(
{
config,
env,
onlyPluginIds: [...params.pluginIds],
pluginSdkResolution: params.pluginSdkResolution,
cache: false,
activate: false,
// Channel setup entries cannot register providers; keep their runtime entry in discovery mode.
forceFullRuntimeForChannelPlugins: true,
preferBuiltPluginArtifacts: useVitestShims,
manifestRegistry: scopedManifestRegistry,
logger: {
info: (message) => log.info(message),
warn: (message) => log.warn(message),
error: (message) => log.error(message),
debug: (message) => log.debug(message),
},
},
{
// Discovery needs the current config, but not the full host runtime graph. The registry
// still supplies its scoped lazy methods around this narrow base runtime.
runtime: createCapabilityRegistrationRuntime(config),
moduleLoader: {
installNativeSdkResolver: false,
loaderFilename: import.meta.url,
...(useVitestShims
? {
aliasOverrides: buildVitestCapabilityShimAliasMap(),
tryNative: false,
}
: {}),
},
},
);
const seenPluginIds = new Set<string>();
const repoRoot = process.cwd();
for (const candidate of discovery.candidates) {
const manifest = manifestByRoot.get(candidate.rootDir);
if (!manifest || manifest.origin !== "bundled" || !pluginIds.has(manifest.id)) {
continue;
}
if (seenPluginIds.has(manifest.id)) {
continue;
}
seenPluginIds.add(manifest.id);
const record = createCapabilityPluginRecord({
id: manifest.id,
name: manifest.name,
description: manifest.description,
version: manifest.version,
source:
env?.VITEST && params.pluginSdkResolution === "dist"
? (resolveBundledPluginRepoEntryPath({
rootDir: repoRoot,
pluginId: manifest.id,
preferBuilt: true,
}) ?? candidate.source)
: candidate.source,
rootDir: candidate.rootDir,
workspaceDir: candidate.workspaceDir,
});
const boundaryLabel = record.source === candidate.source ? "plugin root" : "repo root";
const opened = openRootFileSync({
absolutePath: record.source,
rootPath: record.source === candidate.source ? candidate.rootDir : repoRoot,
boundaryLabel,
rejectHardlinks: false,
skipLexicalRootCheck: true,
});
if (!opened.ok) {
recordCapabilityLoadError(
registry,
record,
describeRootFileOpenFailure({
failure: opened,
subject: "plugin entry path",
boundaryLabel,
filePath: record.source,
}),
);
continue;
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
let mod: OpenClawPluginModule | null;
try {
mod = getModuleLoader(safeSource)(safeSource) as OpenClawPluginModule;
} catch (error) {
recordCapabilityLoadError(registry, record, String(error));
continue;
}
const resolved = resolvePluginModuleExport(mod);
const register = resolved.register;
if (typeof register !== "function") {
record.status = "disabled";
record.error = "plugin export missing register(api)";
registry.plugins.push(record);
continue;
}
try {
const captured = createCapturedPluginRegistration();
register(captured.api);
record.cliBackendIds.push(...captured.cliBackends.map((entry) => entry.id));
record.providerIds.push(...captured.providers.map((entry) => entry.id));
record.embeddingProviderIds.push(...captured.embeddingProviders.map((entry) => entry.id));
record.speechProviderIds.push(...captured.speechProviders.map((entry) => entry.id));
record.realtimeTranscriptionProviderIds.push(
...captured.realtimeTranscriptionProviders.map((entry) => entry.id),
);
record.realtimeVoiceProviderIds.push(
...captured.realtimeVoiceProviders.map((entry) => entry.id),
);
record.mediaUnderstandingProviderIds.push(
...captured.mediaUnderstandingProviders.map((entry) => entry.id),
);
record.transcriptSourceProviderIds.push(
...captured.transcriptSourceProviders.map((entry) => entry.id),
);
record.imageGenerationProviderIds.push(
...captured.imageGenerationProviders.map((entry) => entry.id),
);
record.videoGenerationProviderIds.push(
...captured.videoGenerationProviders.map((entry) => entry.id),
);
record.musicGenerationProviderIds.push(
...captured.musicGenerationProviders.map((entry) => entry.id),
);
record.webFetchProviderIds.push(...captured.webFetchProviders.map((entry) => entry.id));
record.webSearchProviderIds.push(...captured.webSearchProviders.map((entry) => entry.id));
record.migrationProviderIds.push(...captured.migrationProviders.map((entry) => entry.id));
record.memoryEmbeddingProviderIds.push(
...captured.memoryEmbeddingProviders.map((entry) => entry.id),
);
record.agentHarnessIds.push(...captured.agentHarnesses.map((entry) => entry.id));
record.toolNames.push(...captured.tools.map((entry) => entry.name));
registry.cliBackends.push(
...captured.cliBackends.map((backend) => ({
pluginId: record.id,
pluginName: record.name,
backend,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.textTransforms.push(
...captured.textTransforms.map((transforms) => ({
pluginId: record.id,
pluginName: record.name,
transforms,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.providers.push(
...captured.providers.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.embeddingProviders.push(
...captured.embeddingProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.speechProviders.push(
...captured.speechProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.realtimeTranscriptionProviders.push(
...captured.realtimeTranscriptionProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.realtimeVoiceProviders.push(
...captured.realtimeVoiceProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.mediaUnderstandingProviders.push(
...captured.mediaUnderstandingProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.transcriptSourceProviders.push(
...captured.transcriptSourceProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.imageGenerationProviders.push(
...captured.imageGenerationProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.videoGenerationProviders.push(
...captured.videoGenerationProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.musicGenerationProviders.push(
...captured.musicGenerationProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.webFetchProviders.push(
...captured.webFetchProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.webSearchProviders.push(
...captured.webSearchProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.migrationProviders.push(
...captured.migrationProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.memoryEmbeddingProviders.push(
...captured.memoryEmbeddingProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.agentHarnesses.push(
...captured.agentHarnesses.map((harness) => ({
pluginId: record.id,
pluginName: record.name,
harness,
source: record.source,
rootDir: record.rootDir,
})),
);
const declaredToolNames = normalizePluginToolContractNames(record.contracts);
for (const tool of captured.tools) {
const undeclared = findUndeclaredPluginToolNames({
declaredNames: declaredToolNames,
toolNames: [tool.name],
});
if (undeclared.length > 0) {
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: `plugin must declare contracts.tools for: ${undeclared.join(", ")}`,
});
continue;
}
registry.tools.push({
pluginId: record.id,
pluginName: record.name,
factory: () => tool,
names: [tool.name],
declaredNames: declaredToolNames,
optional: false,
source: record.source,
rootDir: record.rootDir,
});
}
registry.plugins.push(record);
} catch (error) {
recordCapabilityLoadError(registry, record, String(error));
}
}
return registry;
}
@@ -9,7 +9,6 @@ import { collectBundledChannelConfigs } from "./bundled-channel-config-metadata.
import {
listBundledPluginMetadata,
resolveBundledPluginGeneratedPath,
resolveBundledPluginRepoEntryPath,
} from "./bundled-plugin-metadata.js";
type BundledPluginMetadata = ReturnType<typeof listBundledPluginMetadata>[number];
@@ -854,75 +853,6 @@ describe("bundled plugin metadata", () => {
).toBe(path.join(pluginRoot, "index.js"));
});
it("resolves bundled repo entry paths from dist before workspace source", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-repo-entry-");
const pluginRoot = path.join(tempRoot, "extensions", "alpha");
const distPluginRoot = path.join(tempRoot, "dist", "extensions", "alpha");
writeJson(path.join(pluginRoot, "package.json"), {
name: "@openclaw/alpha",
version: "0.0.1",
openclaw: {
extensions: ["./index.ts"],
},
});
writeJson(path.join(pluginRoot, "openclaw.plugin.json"), {
id: "alpha",
configSchema: { type: "object" },
});
fs.writeFileSync(path.join(pluginRoot, "index.ts"), "export const source = true;\n", "utf8");
expect(
resolveBundledPluginRepoEntryPath({
rootDir: tempRoot,
pluginId: "alpha",
preferBuilt: true,
}),
).toBe(path.join(pluginRoot, "index.ts"));
fs.mkdirSync(distPluginRoot, { recursive: true });
fs.writeFileSync(path.join(distPluginRoot, "index.js"), "export const built = true;\n", "utf8");
expect(
resolveBundledPluginRepoEntryPath({
rootDir: tempRoot,
pluginId: "alpha",
preferBuilt: true,
}),
).toBe(path.join(distPluginRoot, "index.js"));
});
it("keeps bundled repo entry path resolution inside the plugin directory", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-repo-contained-");
const pluginRoot = path.join(tempRoot, "extensions", "alpha");
writeJson(path.join(pluginRoot, "package.json"), {
name: "@openclaw/alpha",
version: "0.0.1",
openclaw: {
extensions: ["../escape.ts"],
},
});
writeJson(path.join(pluginRoot, "openclaw.plugin.json"), {
id: "alpha",
configSchema: { type: "object" },
});
fs.writeFileSync(path.join(tempRoot, "extensions", "escape.ts"), "export {};\n", "utf8");
fs.mkdirSync(path.join(tempRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(tempRoot, "dist", "extensions", "escape.js"),
"export {};\n",
"utf8",
);
expect(
resolveBundledPluginRepoEntryPath({
rootDir: tempRoot,
pluginId: "alpha",
preferBuilt: true,
}),
).toBeNull();
});
it("merges runtime channel schema metadata with manifest-owned channel config fields", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-channel-configs-");
-50
View File
@@ -71,13 +71,6 @@ function resolveBundledPluginMetadataScanDir(
});
}
function resolveBundledPluginLookupParams(params: { rootDir: string; scanDir?: string }): {
rootDir: string;
scanDir?: string;
} {
return params.scanDir ? params : { rootDir: params.rootDir };
}
function collectBundledPluginMetadata(
resolvedScanDir: string | undefined,
includeChannelConfigs: boolean,
@@ -327,46 +320,3 @@ function resolveBundledPluginEntryCandidate(baseDir: string, entryPath: string):
}
return candidate;
}
/** Resolves the repo entry path for a bundled plugin, preferring source unless requested. */
export function resolveBundledPluginRepoEntryPath(params: {
rootDir: string;
pluginId: string;
preferBuilt?: boolean;
scanDir?: string;
}): string | null {
const metadata = findBundledPluginMetadataById(params.pluginId, {
...resolveBundledPluginLookupParams({
rootDir: params.rootDir,
scanDir: params.scanDir,
}),
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
});
if (!metadata) {
return null;
}
const entryOrder = params.preferBuilt
? [metadata.source.built, metadata.source.source]
: [metadata.source.source, metadata.source.built];
const baseDirs = listBundledPluginEntryBaseDirs({
rootDir: params.rootDir,
pluginDirName: metadata.dirName,
...(params.scanDir ? { scanDir: params.scanDir } : {}),
});
for (const baseDir of baseDirs) {
for (const entryPath of entryOrder) {
const candidate = resolveBundledPluginEntryCandidate(baseDir, entryPath);
if (!candidate) {
continue;
}
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return null;
}
+3 -5
View File
@@ -42,21 +42,19 @@ afterEach(() => {
});
describe("plugin contract registry scoped retries", () => {
it("retries provider loads after a transient plugin-scoped runtime error", async () => {
it("retries when a manifest-declared provider has no runtime entry", async () => {
const loadBundledCapabilityRuntimeRegistry = vi
.fn()
.mockReturnValueOnce(
createMockRuntimeRegistry({
plugin: {
id: "arcee",
status: "error",
error: "transient arcee load failure",
providerIds: [],
status: "loaded",
providerIds: ["arcee"],
webFetchProviderIds: [],
webSearchProviderIds: [],
migrationProviderIds: [],
},
diagnostics: [{ pluginId: "arcee", message: "transient arcee load failure" }],
}),
)
.mockReturnValueOnce(
+13 -16
View File
@@ -136,13 +136,22 @@ function formatBundledCapabilityPluginLoadError(params: {
const diagnostics = params.registry.diagnostics
.filter((entry) => entry.pluginId === params.pluginId)
.map((entry) => entry.message);
const providerIds = params.registry.providers
.filter((entry) => entry.pluginId === params.pluginId)
.map((entry) => entry.provider.id);
const webFetchProviderIds = params.registry.webFetchProviders
.filter((entry) => entry.pluginId === params.pluginId)
.map((entry) => entry.provider.id);
const webSearchProviderIds = params.registry.webSearchProviders
.filter((entry) => entry.pluginId === params.pluginId)
.map((entry) => entry.provider.id);
const detailParts = plugin
? [
`status=${plugin.status}`,
...(plugin.error ? [`error=${plugin.error}`] : []),
`providerIds=[${plugin.providerIds.join(", ")}]`,
`webFetchProviderIds=[${plugin.webFetchProviderIds.join(", ")}]`,
`webSearchProviderIds=[${plugin.webSearchProviderIds.join(", ")}]`,
`providerIds=[${providerIds.join(", ")}]`,
`webFetchProviderIds=[${webFetchProviderIds.join(", ")}]`,
`webSearchProviderIds=[${webSearchProviderIds.join(", ")}]`,
]
: ["plugin record missing"];
if (diagnostics.length > 0) {
@@ -157,13 +166,11 @@ function loadScopedCapabilityRuntimeRegistryEntries<T>(params: {
pluginId: string;
capabilityLabel: string;
loadEntries: (registry: BundledCapabilityRuntimeRegistry) => T[];
loadDeclaredIds: (
plugin: BundledCapabilityRuntimeRegistry["plugins"][number],
) => readonly string[];
}): T[] {
const discovery = discoverOpenClawPlugins({});
let lastFailure: Error | undefined;
// Manifest IDs exist before registration; only observed runtime entries prove the load worked.
for (let attempt = 0; attempt < 2; attempt += 1) {
const registry = loadBundledCapabilityRuntimeRegistry({
pluginIds: [params.pluginId],
@@ -175,18 +182,11 @@ function loadScopedCapabilityRuntimeRegistryEntries<T>(params: {
return entries;
}
const plugin = registry.plugins.find((entry) => entry.id === params.pluginId);
lastFailure = formatBundledCapabilityPluginLoadError({
pluginId: params.pluginId,
capabilityLabel: params.capabilityLabel,
registry,
});
const shouldRetry =
attempt === 0 &&
(!plugin || plugin.status !== "loaded" || params.loadDeclaredIds(plugin).length === 0);
if (!shouldRetry) {
break;
}
}
throw (
@@ -223,7 +223,6 @@ function loadProviderContractEntriesForPluginId(pluginId: string): ProviderContr
pluginId: entry.pluginId,
provider: entry.provider,
})),
loadDeclaredIds: (plugin) => plugin.providerIds,
}).map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
@@ -278,7 +277,6 @@ export function resolveWebFetchProviderContractEntriesForPluginId(
provider: entry.provider,
credentialValue: resolveWebFetchCredentialValue(entry.provider),
})),
loadDeclaredIds: (plugin) => plugin.webFetchProviderIds,
});
}
@@ -307,7 +305,6 @@ export function resolveWebSearchProviderContractEntriesForPluginId(
provider: entry.provider,
credentialValue: resolveWebSearchCredentialValue(entry.provider),
})),
loadDeclaredIds: (plugin) => plugin.webSearchProviderIds,
});
}
+26 -15
View File
@@ -109,30 +109,41 @@ export function runPluginRegisterSyncInRegistry(
export function createPluginModuleLoader(options: {
devSourceRoot?: string | null;
pluginSdkResolution?: PluginSdkResolutionPreference;
aliasOverrides?: Readonly<Record<string, string>>;
tryNative?: boolean;
loaderFilename?: string;
installNativeSdkResolver?: boolean;
}) {
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
const createLoaderForModule = (modulePath: string) => {
installOpenClawPluginSdkNativeResolver({
argv1: process.argv[1],
moduleUrl: import.meta.url,
pluginModulePath: modulePath,
devSourceRoot: options.devSourceRoot,
pluginSdkResolution: options.pluginSdkResolution,
});
if (options.installNativeSdkResolver !== false && options.tryNative !== false) {
installOpenClawPluginSdkNativeResolver({
argv1: process.argv[1],
moduleUrl: import.meta.url,
pluginModulePath: modulePath,
devSourceRoot: options.devSourceRoot,
pluginSdkResolution: options.pluginSdkResolution,
});
}
const defaultAliasMap = buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
import.meta.url,
options.pluginSdkResolution,
options.devSourceRoot,
);
const aliasMap = options.aliasOverrides
? { ...defaultAliasMap, ...options.aliasOverrides }
: defaultAliasMap;
return getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
loaderFilename: modulePath,
loaderFilename: options.loaderFilename ?? modulePath,
devSourceRoot: options.devSourceRoot,
aliasMap: buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
import.meta.url,
options.pluginSdkResolution,
options.devSourceRoot,
),
aliasMap,
pluginSdkResolution: options.pluginSdkResolution,
...(options.tryNative !== undefined ? { tryNative: options.tryNative } : {}),
});
};
return (modulePath: string): unknown =>
+35 -6
View File
@@ -30,8 +30,33 @@ import { createPluginIdScopeSet, normalizePluginIdScope } from "./plugin-scope.j
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { createPluginRegistry, type PluginRegistry } from "./registry.js";
import { getActivePluginRegistry } from "./runtime.js";
import type { PluginRuntime } from "./runtime/types.js";
type PluginModuleLoaderOverrides = Pick<
Parameters<typeof createPluginModuleLoader>[0],
"aliasOverrides" | "tryNative" | "loaderFilename" | "installNativeSdkResolver"
>;
type InternalPluginLoadOverrides = {
moduleLoader: PluginModuleLoaderOverrides;
runtime: Pick<PluginRuntime, "config">;
};
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
return loadOpenClawPluginsInternal(options);
}
/** Internal entry for host-owned snapshots that need a narrow registration runtime. */
export function loadOpenClawPluginsWithInternalOverrides(
options: PluginLoadOptions & { cache: false },
overrides: InternalPluginLoadOverrides,
): PluginRegistry {
return loadOpenClawPluginsInternal(options, overrides);
}
function loadOpenClawPluginsInternal(
options: PluginLoadOptions,
overrides?: InternalPluginLoadOverrides,
): PluginRegistry {
const requestedOnlyPluginIds = normalizePluginIdScope(options.onlyPluginIds);
const requestedOnlyPluginIdSet = createPluginIdScopeSet(requestedOnlyPluginIds);
if (requestedOnlyPluginIdSet && requestedOnlyPluginIdSet.size === 0) {
@@ -80,13 +105,17 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const loadPluginModule = createPluginModuleLoader({
devSourceRoot: context.devSourceRoot,
pluginSdkResolution: options.pluginSdkResolution,
...overrides?.moduleLoader,
});
const runtime = createLazyPluginRuntime({
devSourceRoot: context.devSourceRoot,
pluginSdkResolution: options.pluginSdkResolution,
runtimeOptions: options.runtimeOptions,
loadPluginModule,
});
const runtime = overrides?.runtime
? // The registry wraps this discovery-only base with scoped lazy capabilities.
(overrides.runtime as unknown as PluginRuntime)
: createLazyPluginRuntime({
devSourceRoot: context.devSourceRoot,
pluginSdkResolution: options.pluginSdkResolution,
runtimeOptions: options.runtimeOptions,
loadPluginModule,
});
registryBuilder = createPluginRegistry({
logger,
runtime,
+7 -9
View File
@@ -17,7 +17,6 @@ import {
resolvePluginLoaderModuleConfig,
resolvePluginLoaderTryNative,
resolvePluginRuntimeModulePathWithDiagnostics,
shouldPreferNativeModuleLoad,
type PluginSdkResolutionPreference,
} from "./sdk-alias.js";
import {
@@ -1788,9 +1787,9 @@ describe("plugin sdk alias helpers", () => {
});
it("uses transpiled module loads for source TypeScript plugin entries", () => {
expect(shouldPreferNativeModuleLoad("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(resolvePluginLoaderTryNative("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(
shouldPreferNativeModuleLoad(
resolvePluginLoaderTryNative(
`/repo/${bundledPluginFile("discord", "src/channel.runtime.ts")}`,
),
).toBe(false);
@@ -1807,9 +1806,9 @@ describe("plugin sdk alias helpers", () => {
});
try {
expect(shouldPreferNativeModuleLoad("/repo/dist/plugins/runtime/index.js")).toBe(false);
expect(resolvePluginLoaderTryNative("/repo/dist/plugins/runtime/index.js")).toBe(false);
expect(
shouldPreferNativeModuleLoad(`/repo/${bundledDistPluginFile("browser", "index.js")}`),
resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`),
).toBe(false);
} finally {
Object.defineProperty(process, "versions", {
@@ -1827,9 +1826,9 @@ describe("plugin sdk alias helpers", () => {
});
try {
expect(shouldPreferNativeModuleLoad("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(resolvePluginLoaderTryNative("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(
shouldPreferNativeModuleLoad(`/repo/${bundledDistPluginFile("browser", "index.js")}`),
resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`),
).toBe(true);
} finally {
Object.defineProperty(process, "platform", {
@@ -1867,8 +1866,7 @@ describe("plugin sdk alias helpers", () => {
it("prefers native module loading for bundled plugin dist .js modules, keeps .ts on aliased path", () => {
// Built .js/.mjs/.cjs files under dist/extensions/ should now delegate
// to shouldPreferNativeModuleLoad() — which returns true on Node for
// compiled artifacts, avoiding the slow jiti transform path.
// to native loading on Node for compiled artifacts, avoiding the slow jiti transform path.
expect(
resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, {
preferBuiltDist: true,
+1 -1
View File
@@ -1608,7 +1608,7 @@ function isBundledPluginDistModulePath(modulePath: string): boolean {
return modulePath.replace(/\\/g, "/").includes("/dist/extensions/");
}
export function shouldPreferNativeModuleLoad(modulePath: string): boolean {
function shouldPreferNativeModuleLoad(modulePath: string): boolean {
if (!supportsNativeModuleRuntime()) {
return false;
}