fix(plugins): load explicit hook plugins at startup (#76684) (thanks @MkDev11)

Includes explicitly enabled hook-capable plugins in the Gateway startup runtime scope and adds regression coverage for startup hook plugin gating.
This commit is contained in:
MkDev11
2026-05-03 07:20:42 -07:00
committed by GitHub
parent 877eb1cbed
commit d6900ee500
3 changed files with 271 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Plugins/install: run `npm install` from the managed npm-root manifest so installing one `@openclaw/*` plugin preserves already installed sibling plugins instead of pruning them. Fixes #76571. (#76602) Thanks @byungskers and @crpol.
- Plugins/context-engine: include the selected `plugins.slots.contextEngine` plugin in the gateway startup load plan so external context-engine plugins without `activation.onStartup` in their manifest are loaded before any agent turn resolves the active engine; prevents the "Context engine X is not registered; falling back to default engine legacy" warning after gateway startup. Fixes #76576. Thanks @hclsys.
- Plugins/tools: restore on-demand registry load for path-based plugins (origin "config") so tool factories registered via `plugins.load.paths` are resolved at agent request time when no pre-warmed channel registry is present; prevents "unknown method" errors after gateway startup. Fixes #76598. Thanks @hclsys.
- Plugins/hooks: include explicitly enabled hook-capable plugins in the Gateway startup runtime scope so embedded PI runs can see their `before_prompt_build` and `agent_end` hooks. Fixes #76649. Thanks @wwf3045 and @MkDev11.
- Channels/QQ Bot: resolve structured `clientSecret` SecretRefs before QQ token exchange, expose the QQ Bot secret contract to secrets tooling, and reject legacy `secretref:/...` marker strings. (#74772) Thanks @xialonglee.
- Agents: keep active streamed provider replies alive by refreshing guarded fetch timeouts on raw body chunks and surface true prompt stream timeouts as explicit errors instead of partial assistant fragments. Fixes #76307. (#76633) Thanks @MkDev11.
- Plugins/externalization: keep official ACPX, Google Chat, and LINE install specs on production package names, leaving beta-tag probing to the explicit OpenClaw beta update channel. Thanks @vincentkoc.

View File

@@ -301,6 +301,25 @@ function createManifestRegistryFixture(): PluginManifestRegistry {
providers: [],
cliBackends: [],
},
{
id: "external-hook-capability",
channels: [],
activation: {
onCapabilities: ["hook"],
},
origin: "global",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "external-hook-policy",
channels: [],
origin: "global",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "lossless-claw",
kind: "context-engine",
@@ -847,6 +866,173 @@ describe("resolveGatewayStartupPluginIds", () => {
});
});
it("loads explicit hook-capability plugins at startup", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
enabledPluginIds: ["external-hook-capability"],
allowPluginIds: ["external-hook-capability"],
noConfiguredChannels: true,
memorySlot: "none",
}),
expected: ["external-hook-capability"],
});
});
it("does not ambient-load hook-capability plugins at startup", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
noConfiguredChannels: true,
memorySlot: "none",
}),
expected: ["browser"],
});
});
it("blocks hook-capability plugins when plugins are globally disabled", () => {
expectStartupPluginIdsCase({
config: {
channels: {},
plugins: {
enabled: false,
allow: ["external-hook-capability"],
slots: { memory: "none" },
entries: {
"external-hook-capability": {
enabled: true,
},
},
},
},
expected: [],
});
});
it("blocks hook-capability plugins when explicitly denied", () => {
expectStartupPluginIdsCase({
config: {
channels: {},
plugins: {
allow: ["external-hook-capability"],
deny: ["external-hook-capability"],
slots: { memory: "none" },
entries: {
"external-hook-capability": {
enabled: true,
},
},
},
},
expected: [],
});
});
it("loads explicit hook-policy plugins at startup", () => {
expectStartupPluginIdsCase({
config: {
channels: {},
plugins: {
slots: { memory: "none" },
entries: {
browser: {
enabled: false,
},
"external-hook-policy": {
hooks: {
allowConversationAccess: true,
allowPromptInjection: true,
},
},
},
},
},
expected: ["external-hook-policy"],
});
});
it.each([
["conversation access", { allowConversationAccess: true }],
["prompt injection", { allowPromptInjection: true }],
] as const)("loads hook-policy plugins with only %s enabled", (_name, hooks) => {
expectStartupPluginIdsCase({
config: {
channels: {},
plugins: {
slots: { memory: "none" },
entries: {
browser: {
enabled: false,
},
"external-hook-policy": {
hooks,
},
},
},
},
expected: ["external-hook-policy"],
});
});
it("keeps hook-policy plugins behind restrictive allowlists", () => {
expectStartupPluginIdsCase({
config: {
channels: {},
plugins: {
allow: ["browser"],
slots: { memory: "none" },
entries: {
browser: {
enabled: false,
},
"external-hook-policy": {
hooks: {
allowPromptInjection: true,
},
},
},
},
},
expected: [],
});
});
it("does not let effective-only hook policy bypass the authored startup allowlist", () => {
const activationSourceConfig = {
channels: {},
plugins: {
allow: ["browser"],
slots: { memory: "none" },
entries: {
browser: {
enabled: false,
},
},
},
} as OpenClawConfig;
const runtimeConfig = {
channels: {},
plugins: {
allow: ["browser", "external-hook-policy"],
slots: { memory: "none" },
entries: {
browser: {
enabled: false,
},
"external-hook-policy": {
hooks: {
allowPromptInjection: true,
},
},
},
},
} as OpenClawConfig;
expectStartupPluginIdsCase({
config: runtimeConfig,
activationSourceConfig,
expected: [],
});
});
it("starts bundled sidecars selected by root config activation paths", () => {
const rawConfig = {
browser: {

View File

@@ -37,6 +37,8 @@ export type GatewayStartupPluginPlan = {
pluginIds: readonly string[];
};
type NormalizedPluginsConfig = ReturnType<typeof normalizePluginsConfigWithRegistry>;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
@@ -282,6 +284,76 @@ function canStartConfiguredRootPlugin(params: {
return true;
}
function hasExplicitHookPolicyConfig(
entry: NormalizedPluginsConfig["entries"][string] | undefined,
): boolean {
return (
entry?.hooks?.allowConversationAccess === true || entry?.hooks?.allowPromptInjection === true
);
}
function hasHookRuntimeStartupIntent(params: {
plugin: InstalledPluginIndexRecord;
manifest: PluginManifestRecord | undefined;
activationSourcePlugins: NormalizedPluginsConfig;
}): boolean {
if (params.manifest?.activation?.onCapabilities?.includes("hook")) {
return true;
}
return hasExplicitHookPolicyConfig(
params.activationSourcePlugins.entries[params.plugin.pluginId],
);
}
function canStartExplicitHookPlugin(params: {
plugin: InstalledPluginIndexRecord;
manifest: PluginManifestRecord | undefined;
config: OpenClawConfig;
pluginsConfig: NormalizedPluginsConfig;
activationSource: {
plugins: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
};
activationSourcePlugins: NormalizedPluginsConfig;
}): boolean {
const hasHookPolicyIntent = hasExplicitHookPolicyConfig(
params.activationSourcePlugins.entries[params.plugin.pluginId],
);
if (
!hasHookRuntimeStartupIntent({
plugin: params.plugin,
manifest: params.manifest,
activationSourcePlugins: params.activationSourcePlugins,
})
) {
return false;
}
if (!params.pluginsConfig.enabled || !params.activationSourcePlugins.enabled) {
return false;
}
if (
params.pluginsConfig.deny.includes(params.plugin.pluginId) ||
params.activationSourcePlugins.deny.includes(params.plugin.pluginId)
) {
return false;
}
if (
params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false ||
params.activationSourcePlugins.entries[params.plugin.pluginId]?.enabled === false
) {
return false;
}
const activationState = resolveEffectivePluginActivationState({
id: params.plugin.pluginId,
origin: params.plugin.origin,
config: params.pluginsConfig,
rootConfig: params.config,
enabledByDefault: params.plugin.enabledByDefault,
activationSource: params.activationSource,
});
return activationState.enabled && (activationState.explicitlyEnabled || hasHookPolicyIntent);
}
function canStartConfiguredChannelPlugin(params: {
plugin: InstalledPluginIndexRecord;
config: OpenClawConfig;
@@ -499,6 +571,18 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: {
) {
return true;
}
if (
canStartExplicitHookPlugin({
plugin,
manifest,
config: params.config,
pluginsConfig,
activationSource,
activationSourcePlugins,
})
) {
return true;
}
if (
!shouldConsiderForGatewayStartup({
plugin,