diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 4f0893ce2621..08142f2b8694 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index b6a94a652037..bce93faf1704 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -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 diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index 702be65b54f0..e040e505df02 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -24,6 +24,8 @@ export default defineBundledChannelEntry({ registerFull(api) { registerDiscordActivities(api); registerDiscordSubagentHooks(api); + }, + registerCapabilities(api) { api.registerTranscriptSourceProvider(discordVoiceTranscriptsSourceProvider); }, }); diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index 827bc6ddfd1e..7432ffde264b 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -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); }); }); diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 8fd57b87b07a..1dab4e100a90 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -63,6 +63,7 @@ type DefineBundledChannelEntryOptions = { features?: BundledChannelEntryFeatures; registerCliMetadata?: (api: OpenClawPluginApi) => void; registerFull?: (api: OpenClawPluginApi) => void; + registerCapabilities?: (api: OpenClawPluginApi) => void; }; type DefineBundledChannelSetupEntryOptions = { @@ -482,6 +483,7 @@ export function defineBundledChannelEntry({ features, registerCliMetadata, registerFull, + registerCapabilities, }: DefineBundledChannelEntryOptions): BundledChannelEntryContract { const resolvedConfigSchema: ChannelEntryConfigSchema = typeof configSchema === "function" @@ -540,6 +542,7 @@ export function defineBundledChannelEntry({ 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({ 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({ } profile("bundled-register:registerCliMetadata", () => registerCliMetadata?.(api)); profile("bundled-register:registerFull", () => registerFull?.(api)); + profile("bundled-register:registerCapabilities", () => registerCapabilities?.(api)); }, loadChannelPlugin, ...(loadChannelOutbound ? { loadChannelOutbound } : {}), diff --git a/src/plugin-sdk/core.test.ts b/src/plugin-sdk/core.test.ts index f77e54f873ce..12161f3254c2 100644 --- a/src/plugin-sdk/core.test.ts +++ b/src/plugin-sdk/core.test.ts @@ -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); }); }); diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index f38caa17b14f..acfe9f54f407 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -502,6 +502,7 @@ type DefineChannelPluginEntryOptions = { setRuntime?: (runtime: PluginRuntime) => void; registerCliMetadata?: (api: OpenClawPluginApi) => void; registerFull?: (api: OpenClawPluginApi) => void; + registerCapabilities?: (api: OpenClawPluginApi) => void; }; type DefinedChannelPluginEntry = { @@ -575,6 +576,7 @@ export function defineChannelPluginEntry({ setRuntime, registerCliMetadata, registerFull, + registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry { const resolvedConfigSchema: ChannelEntryConfigSchema = typeof configSchema === "function" @@ -592,12 +594,14 @@ export function defineChannelPluginEntry({ } 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({ } registerCliMetadata?.(api); registerFull?.(api); + registerCapabilities?.(api); }, }; return { diff --git a/src/plugins/bundled-capability-runtime.test.ts b/src/plugins/bundled-capability-runtime.test.ts new file mode 100644 index 000000000000..412b275afee5 --- /dev/null +++ b/src/plugins/bundled-capability-runtime.test.ts @@ -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); + }); +}); diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index dab5429e4bb8..d503a1de307b 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -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 { ); } -function applyVitestCapabilityAliasOverrides(params: { - aliasMap: Record; - pluginSdkResolution?: PluginSdkResolutionPreference; - env?: PluginLoadOptions["env"]; -}): Record { - 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 { 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, +): Pick { 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(); - 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; } diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 1cd0c012fb22..cb3a6c90052d 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -9,7 +9,6 @@ import { collectBundledChannelConfigs } from "./bundled-channel-config-metadata. import { listBundledPluginMetadata, resolveBundledPluginGeneratedPath, - resolveBundledPluginRepoEntryPath, } from "./bundled-plugin-metadata.js"; type BundledPluginMetadata = ReturnType[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-"); diff --git a/src/plugins/bundled-plugin-metadata.ts b/src/plugins/bundled-plugin-metadata.ts index 3bcd3153bbab..90011528aced 100644 --- a/src/plugins/bundled-plugin-metadata.ts +++ b/src/plugins/bundled-plugin-metadata.ts @@ -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; -} diff --git a/src/plugins/contracts/registry.retry.test.ts b/src/plugins/contracts/registry.retry.test.ts index 387fbee962c2..953acb2df687 100644 --- a/src/plugins/contracts/registry.retry.test.ts +++ b/src/plugins/contracts/registry.retry.test.ts @@ -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( diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index e40a09d0654b..5f214301223f 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -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(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(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, }); } diff --git a/src/plugins/loader-module-runtime.ts b/src/plugins/loader-module-runtime.ts index 8ac2126b37aa..91c6b8b2f132 100644 --- a/src/plugins/loader-module-runtime.ts +++ b/src/plugins/loader-module-runtime.ts @@ -109,30 +109,41 @@ export function runPluginRegisterSyncInRegistry( export function createPluginModuleLoader(options: { devSourceRoot?: string | null; pluginSdkResolution?: PluginSdkResolutionPreference; + aliasOverrides?: Readonly>; + 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 => diff --git a/src/plugins/loader-runtime-load.ts b/src/plugins/loader-runtime-load.ts index 3de05d91da49..b7d832fc3637 100644 --- a/src/plugins/loader-runtime-load.ts +++ b/src/plugins/loader-runtime-load.ts @@ -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[0], + "aliasOverrides" | "tryNative" | "loaderFilename" | "installNativeSdkResolver" +>; +type InternalPluginLoadOverrides = { + moduleLoader: PluginModuleLoaderOverrides; + runtime: Pick; +}; 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, diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index e3f5060c7b07..4426162e3208 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -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, diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index f3529b938d8d..a316a0e6deb3 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -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; }