fix(plugins): cache discovery registration snapshots

Co-authored-by: junpei.o <14040213+livingghost@users.noreply.github.com>
Co-authored-by: Yoshiaki Okuyama <okuyam2y@gmail.com>
Co-authored-by: Shion Eria <shioneria@foxmail.com>
Co-authored-by: Billy Shih <1472300+bbshih@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-04-24 23:52:51 +01:00
parent 9eeceaca43
commit 0c46e8000e
9 changed files with 345 additions and 83 deletions

View File

@@ -11,7 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/failover: stop body-less HTTP 400/422 proxy failures from defaulting to `"format"` classification, so embedded retries surface the opaque provider failure instead of falling into a compaction loop. Fixes #66462. (#67024) Thanks @altaywtf and @HongzhuLiu.
- Plugins/loader: use cached discovery-mode snapshot loads for read-only plugin capability lookups, keep snapshot caches isolated from active Gateway registries, and make same-plugin channel/HTTP route re-registration idempotent so repeated snapshot or hot-reload paths no longer rerun full plugin side effects or accumulate duplicate surfaces. Fixes #51781, #52031, #54181, and #57514. Thanks @livingghost, @okuyam2y, @ShionEria, and @bbshih.
## 2026.4.24
### Breaking

View File

@@ -1,2 +1,2 @@
b4fb88ca434fb92a38bb068cc0b1863b1f22bcde2ce21499c3077ea7e8460775 plugin-sdk-api-baseline.json
0f373c8820c0cd17b13dddf520dd286d9dec85234eb0a7f94dac07432572ede7 plugin-sdk-api-baseline.jsonl
eb5c790aaa54be7b1380eb5a162db50dd314e052aedb5e608290092c33d999f2 plugin-sdk-api-baseline.json
0d2fd80f69e0c3488b6bdbbbb035b08ab108637790d1f30b8e4f84c71c5bc8e2 plugin-sdk-api-baseline.jsonl

View File

@@ -366,6 +366,21 @@ activation. The loader still falls back to `activate(api)` for older plugins,
but bundled plugins and new external plugins should treat `register` as the
public contract.
`api.registrationMode` tells a plugin why its entry is being loaded:
| Mode | Meaning |
| --------------- | ------------------------------------------------------------------------------------------------------ |
| `full` | Runtime activation. Register tools, hooks, services, commands, routes, and other live side effects. |
| `discovery` | Read-only capability discovery. Register providers and metadata, but skip expensive live side effects. |
| `setup-only` | Channel setup metadata loading through a lightweight setup entry. |
| `setup-runtime` | Channel setup loading that also needs the runtime entry. |
| `cli-metadata` | CLI command metadata collection only. |
Plugin entries that open sockets, databases, background workers, or long-lived
clients should guard those side effects with `api.registrationMode === "full"`.
Discovery loads are cached separately from activating loads and do not replace
the running Gateway registry.
Common registration methods:
| Method | What it registers |

View File

@@ -3415,13 +3415,107 @@ module.exports = { id: "throws-after-import", register() {} };`,
);
});
it("throws when activate:false is used without cache:false", () => {
expect(() => loadOpenClawPlugins({ activate: false })).toThrow(
"activate:false requires cache:false",
);
expect(() => loadOpenClawPlugins({ activate: false, cache: true })).toThrow(
"activate:false requires cache:false",
);
it("uses discovery registration mode for non-activating loads", () => {
useNoBundledPlugins();
const marker = "__openclawDiscoveryModeTest";
const plugin = writePlugin({
id: "discovery-mode-test",
filename: "discovery-mode-test.cjs",
body: `module.exports = {
id: "discovery-mode-test",
register(api) {
globalThis.${marker} = globalThis.${marker} || [];
globalThis.${marker}.push(api.registrationMode);
api.registerProvider({ id: "discovery-provider", label: "Discovery Provider", auth: [] });
api.registerTool({
name: "discovery_tool",
description: "Discovery tool",
parameters: {},
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
});
},
};`,
});
const config = {
plugins: {
load: { paths: [plugin.file] },
allow: ["discovery-mode-test"],
},
};
const snapshot = loadOpenClawPlugins({
activate: false,
cache: false,
workspaceDir: plugin.dir,
config,
});
expect((globalThis as Record<string, unknown>)[marker]).toEqual(["discovery"]);
expect(snapshot.providers.map((entry) => entry.provider.id)).toEqual(["discovery-provider"]);
expect(snapshot.tools.flatMap((entry) => entry.names)).toContain("discovery_tool");
loadOpenClawPlugins({
cache: false,
workspaceDir: plugin.dir,
config,
});
expect((globalThis as Record<string, unknown>)[marker]).toEqual(["discovery", "full"]);
delete (globalThis as Record<string, unknown>)[marker];
});
it("caches non-activating snapshots without restoring global side effects", () => {
useNoBundledPlugins();
clearPluginCommands();
const marker = "__openclawSnapshotCacheRegisterCount";
const plugin = writePlugin({
id: "snapshot-cache",
filename: "snapshot-cache.cjs",
body: `module.exports = {
id: "snapshot-cache",
register(api) {
globalThis.${marker} = (globalThis.${marker} || 0) + 1;
api.registerCommand({
name: "snapshot-command",
description: "Snapshot command",
handler: async () => ({ text: "ok" }),
});
},
};`,
});
const options = {
activate: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["snapshot-cache"],
},
},
onlyPluginIds: ["snapshot-cache"],
};
const first = loadOpenClawPlugins(options);
const second = loadOpenClawPlugins(options);
expect(second).toBe(first);
expect((globalThis as Record<string, unknown>)[marker]).toBe(1);
expect(first.commands.map((entry) => entry.command.name)).toEqual(["snapshot-command"]);
expect(getPluginCommandSpecs()).toEqual([]);
const active = loadOpenClawPlugins({
workspaceDir: plugin.dir,
config: options.config,
onlyPluginIds: ["snapshot-cache"],
});
expect(active).not.toBe(first);
expect((globalThis as Record<string, unknown>)[marker]).toBe(2);
expect(getPluginCommandSpecs()).toEqual([
{
name: "snapshot-command",
description: "Snapshot command",
acceptsArgs: false,
},
]);
delete (globalThis as Record<string, unknown>)[marker];
});
it("re-initializes global hook runner when serving registry from cache", () => {
@@ -4061,7 +4155,7 @@ module.exports = { id: "throws-after-import", register() {} };`,
},
},
{
label: "rejects duplicate channel ids during plugin registration",
label: "updates duplicate channel ids during same-plugin registration",
pluginId: "channel-dup",
body: `module.exports = { id: "channel-dup", register(api) {
api.registerChannel({
@@ -4103,11 +4197,9 @@ module.exports = { id: "throws-after-import", register() {} };`,
} };`,
assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1);
expectRegistryErrorDiagnostic({
registry,
pluginId: "channel-dup",
message: "channel already registered: demo (channel-dup)",
});
expect(
registry.channels.find((entry) => entry.plugin.id === "demo")?.plugin.meta?.label,
).toBe("Demo Duplicate");
},
},
{
@@ -4417,14 +4509,14 @@ module.exports = { id: "throws-after-import", register() {} };`,
},
},
{
label: "same plugin can replace its own route",
label: "same plugin can implicitly replace its own route",
buildPlugins: () => [
writePlugin({
id: "http-route-replace-self",
filename: "http-route-replace-self.cjs",
body: `module.exports = { id: "http-route-replace-self", register(api) {
api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false });
api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true });
api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => true });
} };`,
}),
],

View File

@@ -66,7 +66,7 @@ import {
restorePluginInteractiveHandlers,
} from "./interactive-registry.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
import type { PluginManifestContracts } from "./manifest.js";
import {
@@ -124,6 +124,7 @@ import type {
OpenClawPluginDefinition,
OpenClawPluginModule,
PluginLogger,
PluginRegistrationMode,
} from "./types.js";
export type PluginLoadResult = PluginRegistry;
@@ -808,6 +809,7 @@ function buildCacheKey(params: {
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
pluginSdkResolution?: PluginSdkResolutionPreference;
coreGatewayMethodNames?: string[];
activate?: boolean;
}): string {
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
@@ -845,12 +847,13 @@ function buildCacheKey(params: {
params.installBundledRuntimeDeps === false ? "skip-runtime-deps" : "install-runtime-deps";
const runtimeSubagentMode = params.runtimeSubagentMode ?? "default";
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
const activationMode = params.activate === false ? "snapshot" : "active";
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
...params.plugins,
installs,
loadPaths,
activationMetadataKey: params.activationMetadataKey ?? "",
})}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
})}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}::${activationMode}`;
}
function matchesScopedPluginRequest(params: {
@@ -933,6 +936,87 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
);
}
type PluginRegistrationPlan = {
/** Public compatibility label passed to plugin register(api). */
mode: PluginRegistrationMode;
/** Load a setup entry instead of the normal runtime entry. */
loadSetupEntry: boolean;
/** Setup flow also needs the runtime channel entry for runtime setters/plugin shape. */
loadSetupRuntimeEntry: boolean;
/** Apply runtime capability policy such as memory-slot selection. */
runRuntimeCapabilityPolicy: boolean;
/** Register metadata that only belongs to live activation, not discovery snapshots. */
runFullActivationOnlyRegistrations: boolean;
};
/**
* Convert loader intent into explicit behavior flags.
*
* Registration modes are plugin-facing labels; this plan is the internal source
* of truth for which entrypoint to load and which activation-only policies run.
*/
function resolvePluginRegistrationPlan(params: {
canLoadScopedSetupOnlyChannelPlugin: boolean;
scopedSetupOnlyChannelPluginRequested: boolean;
requireSetupEntryForSetupOnlyChannelPlugins: boolean;
enableStateEnabled: boolean;
shouldLoadModules: boolean;
validateOnly: boolean;
shouldActivate: boolean;
manifestRecord: PluginManifestRecord;
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
preferSetupRuntimeForChannelPlugins: boolean;
}): PluginRegistrationPlan | null {
if (params.canLoadScopedSetupOnlyChannelPlugin) {
return {
mode: "setup-only",
loadSetupEntry: true,
loadSetupRuntimeEntry: false,
runRuntimeCapabilityPolicy: false,
runFullActivationOnlyRegistrations: false,
};
}
if (
params.scopedSetupOnlyChannelPluginRequested &&
params.requireSetupEntryForSetupOnlyChannelPlugins
) {
return null;
}
if (!params.enableStateEnabled) {
return null;
}
const loadSetupRuntimeEntry =
params.shouldLoadModules &&
!params.validateOnly &&
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: params.manifestRecord.channels,
setupSource: params.manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
params.manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg: params.cfg,
env: params.env,
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
});
if (loadSetupRuntimeEntry) {
return {
mode: "setup-runtime",
loadSetupEntry: true,
loadSetupRuntimeEntry: true,
runRuntimeCapabilityPolicy: false,
runFullActivationOnlyRegistrations: false,
};
}
const mode = params.shouldActivate ? "full" : "discovery";
return {
mode,
loadSetupEntry: false,
loadSetupRuntimeEntry: false,
runRuntimeCapabilityPolicy: true,
runFullActivationOnlyRegistrations: mode === "full",
};
}
function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
const env = options.env ?? process.env;
const cfg = applyTestPluginDefaults(options.config ?? {}, env);
@@ -976,6 +1060,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
runtimeSubagentMode,
pluginSdkResolution: options.pluginSdkResolution,
coreGatewayMethodNames,
activate: options.activate,
});
return {
env,
@@ -1053,6 +1138,15 @@ function getCompatibleActivePluginRegistry(
if (loadContext.cacheKey === activeCacheKey) {
return activeRegistry;
}
if (!loadContext.shouldActivate) {
const activatingCacheKey = resolvePluginLoadCacheContext({
...options,
activate: true,
}).cacheKey;
if (activatingCacheKey === activeCacheKey) {
return activeRegistry;
}
}
if (
loadContext.runtimeSubagentMode === "default" &&
getActivePluginRuntimeSubagentMode() === "gateway-bindable"
@@ -1067,6 +1161,19 @@ function getCompatibleActivePluginRegistry(
if (gatewayBindableCacheKey === activeCacheKey) {
return activeRegistry;
}
if (!loadContext.shouldActivate) {
const activatingGatewayBindableCacheKey = resolvePluginLoadCacheContext({
...options,
activate: true,
runtimeOptions: {
...options.runtimeOptions,
allowGatewaySubagentBinding: true,
},
}).cacheKey;
if (activatingGatewayBindableCacheKey === activeCacheKey) {
return activeRegistry;
}
}
}
return undefined;
}
@@ -1851,13 +1958,6 @@ function activatePluginRegistry(
}
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
// Snapshot (non-activating) loads must disable the cache to avoid storing a registry
// whose commands were never globally registered.
if (options.activate === false && options.cache !== false) {
throw new Error(
"loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence",
);
}
const {
env,
cfg,
@@ -1882,21 +1982,21 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cacheEnabled) {
const cached = getCachedPluginRegistry(cacheKey);
if (cached) {
restoreRegisteredAgentHarnesses(cached.agentHarnesses);
restorePluginCommands(cached.commands ?? []);
restoreRegisteredCompactionProviders(cached.compactionProviders);
restoreDetachedTaskLifecycleRuntimeRegistration(cached.detachedTaskRuntimeRegistration);
restorePluginInteractiveHandlers(cached.interactiveHandlers ?? []);
restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders);
restoreMemoryPluginState({
capability: cached.memoryCapability,
corpusSupplements: cached.memoryCorpusSupplements,
promptBuilder: cached.memoryPromptBuilder,
promptSupplements: cached.memoryPromptSupplements,
flushPlanResolver: cached.memoryFlushPlanResolver,
runtime: cached.memoryRuntime,
});
if (shouldActivate) {
restoreRegisteredAgentHarnesses(cached.agentHarnesses);
restorePluginCommands(cached.commands ?? []);
restoreRegisteredCompactionProviders(cached.compactionProviders);
restoreDetachedTaskLifecycleRuntimeRegistration(cached.detachedTaskRuntimeRegistration);
restorePluginInteractiveHandlers(cached.interactiveHandlers ?? []);
restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders);
restoreMemoryPluginState({
capability: cached.memoryCapability,
corpusSupplements: cached.memoryCorpusSupplements,
promptBuilder: cached.memoryPromptBuilder,
promptSupplements: cached.memoryPromptSupplements,
flushPlanResolver: cached.memoryFlushPlanResolver,
runtime: cached.memoryRuntime,
});
activatePluginRegistry(
cached.registry,
cacheKey,
@@ -2178,33 +2278,27 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const scopedSetupOnlyChannelPluginRequested =
includeSetupOnlyChannelPlugins &&
!validateOnly &&
onlyPluginIdSet &&
Boolean(onlyPluginIdSet) &&
manifestRecord.channels.length > 0 &&
(!enableState.enabled || forceSetupOnlyChannelPlugins);
const canLoadScopedSetupOnlyChannelPlugin =
scopedSetupOnlyChannelPluginRequested &&
(!requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource));
const registrationMode = canLoadScopedSetupOnlyChannelPlugin
? "setup-only"
: scopedSetupOnlyChannelPluginRequested && requireSetupEntryForSetupOnlyChannelPlugins
? null
: enableState.enabled
? shouldLoadModules &&
!validateOnly &&
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: manifestRecord.channels,
setupSource: manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
})
? "setup-runtime"
: "full"
: null;
const registrationPlan = resolvePluginRegistrationPlan({
canLoadScopedSetupOnlyChannelPlugin,
scopedSetupOnlyChannelPluginRequested,
requireSetupEntryForSetupOnlyChannelPlugins,
enableStateEnabled: enableState.enabled,
shouldLoadModules,
validateOnly,
shouldActivate,
manifestRecord,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
});
if (!registrationMode) {
if (!registrationPlan) {
record.status = "disabled";
record.error = enableState.reason;
markPluginActivationDisabled(record, enableState.reason);
@@ -2212,6 +2306,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
seenIds.set(pluginId, candidate.origin);
continue;
}
const registrationMode = registrationPlan.mode;
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
@@ -2340,7 +2435,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
// Exception: the dreaming engine (memory-core by default) must load alongside the
// selected memory slot plugin so dreaming can run even when lancedb holds the slot.
if (
registrationMode === "full" &&
registrationPlan.runRuntimeCapabilityPolicy &&
candidate.origin === "bundled" &&
hasKind(manifestRecord.kind, "memory")
) {
@@ -2368,7 +2463,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (!shouldLoadModules && registrationMode === "full") {
if (!shouldLoadModules && registrationPlan.runRuntimeCapabilityPolicy) {
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
@@ -2414,8 +2509,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
const loadSource =
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
runtimeSetupSource
registrationPlan.loadSetupEntry && runtimeSetupSource
? runtimeSetupSource
: runtimeCandidateSource;
const moduleLoadSource = resolveCanonicalDistRuntimeSource(loadSource);
@@ -2461,10 +2555,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
manifestRecord.setupSource
) {
if (registrationPlan.loadSetupEntry && manifestRecord.setupSource) {
const setupRegistration = resolveSetupChannelRegistration(mod, {
installRuntimeDeps:
shouldInstallBundledRuntimeDeps &&
@@ -2507,7 +2598,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
let mergedSetupRegistration = setupRegistration;
let runtimeSetterApplied = false;
if (
registrationMode === "setup-runtime" &&
registrationPlan.loadSetupRuntimeEntry &&
setupRegistration.usesBundledSetupContract &&
runtimeCandidateSource !== safeSource
) {
@@ -2685,7 +2776,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
memorySlotMatched = true;
}
if (registrationMode === "full") {
if (registrationPlan.runRuntimeCapabilityPolicy) {
if (pluginId !== dreamingEngineId) {
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
@@ -2711,7 +2802,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
if (registrationMode === "full") {
if (registrationPlan.runFullActivationOnlyRegistrations) {
if (definition?.reload) {
registerReload(record, definition.reload);
}

View File

@@ -231,7 +231,7 @@ function resolveRuntimeProviderPluginLoadState(
{
onlyPluginIds: providerPluginIds,
pluginSdkResolution: params.pluginSdkResolution,
cache: params.cache ?? false,
cache: params.cache ?? true,
activate: params.activate ?? false,
},
);

View File

@@ -164,7 +164,7 @@ function expectLastRuntimeRegistryLoad(params?: {
}) {
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
cache: false,
cache: true,
activate: false,
...(params?.env ? { env: params.env } : {}),
...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}),
@@ -401,7 +401,7 @@ describe("resolvePluginProviders", () => {
expect.objectContaining({
workspaceDir: "/workspace/explicit",
env,
cache: false,
cache: true,
activate: false,
}),
);
@@ -764,7 +764,7 @@ describe("resolvePluginProviders", () => {
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/workspace/runtime",
cache: false,
cache: true,
activate: false,
}),
);
@@ -790,7 +790,7 @@ describe("resolvePluginProviders", () => {
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/workspace/runtime",
cache: false,
cache: true,
activate: false,
}),
);

View File

@@ -196,6 +196,27 @@ const activePluginHookRegistrations = resolveGlobalSingleton<
type HookRegistration = { event: string; handler: Parameters<typeof registerInternalHook>[1] };
type HookRollbackEntry = { name: string; previousRegistrations: HookRegistration[] };
type PluginRegistrationCapabilities = {
/** Broad registry writes that discovery and live activation both need. */
capabilityHandlers: boolean;
/** Runtime channel registration is suppressed for setup-only metadata loads. */
runtimeChannel: boolean;
};
/**
* Keep mode decoding centralized. PluginRegistrationMode is the public label;
* registry code should consume these booleans instead of duplicating string
* checks across individual registration handlers.
*/
function resolvePluginRegistrationCapabilities(
mode: PluginRegistrationMode,
): PluginRegistrationCapabilities {
return {
capabilityHandlers: mode === "full" || mode === "discovery",
runtimeChannel: mode !== "setup-only",
};
}
export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry = createEmptyPluginRegistry();
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
@@ -621,7 +642,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
if (!existing) {
return;
}
if (!params.replaceExisting) {
if (!params.replaceExisting && existing.pluginId !== record.id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
@@ -671,6 +692,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registration: OpenClawPluginChannelRegistration | ChannelPlugin,
mode: PluginRegistrationMode = "full",
) => {
const registrationCapabilities = resolvePluginRegistrationCapabilities(mode);
const normalized =
typeof (registration as OpenClawPluginChannelRegistration).plugin === "object"
? (registration as OpenClawPluginChannelRegistration)
@@ -686,7 +708,22 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
const id = plugin.id;
const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id);
if (mode !== "setup-only" && existingRuntime) {
if (registrationCapabilities.runtimeChannel && existingRuntime) {
if (existingRuntime.pluginId === record.id) {
existingRuntime.plugin = plugin;
existingRuntime.pluginName = record.name;
existingRuntime.source = record.source;
existingRuntime.rootDir = record.rootDir;
const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id);
if (existingSetup) {
existingSetup.plugin = plugin;
existingSetup.pluginName = record.name;
existingSetup.source = record.source;
existingSetup.enabled = record.enabled;
existingSetup.rootDir = record.rootDir;
}
return;
}
pushDiagnostic({
level: "error",
pluginId: record.id,
@@ -697,6 +734,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id);
if (existingSetup) {
if (existingSetup.pluginId === record.id) {
existingSetup.plugin = plugin;
existingSetup.pluginName = record.name;
existingSetup.source = record.source;
existingSetup.enabled = record.enabled;
existingSetup.rootDir = record.rootDir;
return;
}
pushDiagnostic({
level: "error",
pluginId: record.id,
@@ -714,7 +759,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
enabled: record.enabled,
rootDir: record.rootDir,
});
if (mode === "setup-only") {
if (!registrationCapabilities.runtimeChannel) {
return;
}
registry.channels.push({
@@ -1412,6 +1457,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
},
): OpenClawPluginApi => {
const registrationMode = params.registrationMode ?? "full";
const registrationCapabilities = resolvePluginRegistrationCapabilities(registrationMode);
return buildPluginApi({
id: record.id,
name: record.name,
@@ -1426,7 +1472,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
logger: normalizeLogger(registryParams.logger),
resolvePath: (input: string) => resolveUserPath(input),
handlers: {
...(registrationMode === "full"
...(registrationCapabilities.capabilityHandlers
? {
registerTool: (tool, opts) => registerTool(record, tool, opts),
registerHook: (events, handler, opts) =>

View File

@@ -2000,7 +2000,25 @@ export type OpenClawPluginDefinition = {
export type OpenClawPluginModule = OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void);
export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime" | "cli-metadata";
/**
* Public label exposed to plugin `register(api)` calls.
*
* Keep this as a compatibility signal for plugin authors. Loader internals
* should derive explicit capability booleans from the mode instead of branching
* on raw strings throughout the code path.
*
* - `full`: live runtime activation; long-lived side effects may start.
* - `discovery`: read-only capability discovery; skip sockets/workers/clients.
* - `setup-only`: lightweight channel setup entry only.
* - `setup-runtime`: setup flow that also needs the runtime channel entry.
* - `cli-metadata`: CLI command metadata collection.
*/
export type PluginRegistrationMode =
| "full"
| "discovery"
| "setup-only"
| "setup-runtime"
| "cli-metadata";
export type PluginConfigMigration = (config: OpenClawConfig) =>
| {