Skip to content

Commit d6900ee

Browse files
authored
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.
1 parent 877eb1c commit d6900ee

3 files changed

Lines changed: 271 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
3434
- 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.
3535
- 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.
3636
- 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.
37+
- 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.
3738
- 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.
3839
- 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.
3940
- 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.

src/plugins/channel-plugin-ids.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,25 @@ function createManifestRegistryFixture(): PluginManifestRegistry {
301301
providers: [],
302302
cliBackends: [],
303303
},
304+
{
305+
id: "external-hook-capability",
306+
channels: [],
307+
activation: {
308+
onCapabilities: ["hook"],
309+
},
310+
origin: "global",
311+
enabledByDefault: undefined,
312+
providers: [],
313+
cliBackends: [],
314+
},
315+
{
316+
id: "external-hook-policy",
317+
channels: [],
318+
origin: "global",
319+
enabledByDefault: undefined,
320+
providers: [],
321+
cliBackends: [],
322+
},
304323
{
305324
id: "lossless-claw",
306325
kind: "context-engine",
@@ -847,6 +866,173 @@ describe("resolveGatewayStartupPluginIds", () => {
847866
});
848867
});
849868

869+
it("loads explicit hook-capability plugins at startup", () => {
870+
expectStartupPluginIdsCase({
871+
config: createStartupConfig({
872+
enabledPluginIds: ["external-hook-capability"],
873+
allowPluginIds: ["external-hook-capability"],
874+
noConfiguredChannels: true,
875+
memorySlot: "none",
876+
}),
877+
expected: ["external-hook-capability"],
878+
});
879+
});
880+
881+
it("does not ambient-load hook-capability plugins at startup", () => {
882+
expectStartupPluginIdsCase({
883+
config: createStartupConfig({
884+
noConfiguredChannels: true,
885+
memorySlot: "none",
886+
}),
887+
expected: ["browser"],
888+
});
889+
});
890+
891+
it("blocks hook-capability plugins when plugins are globally disabled", () => {
892+
expectStartupPluginIdsCase({
893+
config: {
894+
channels: {},
895+
plugins: {
896+
enabled: false,
897+
allow: ["external-hook-capability"],
898+
slots: { memory: "none" },
899+
entries: {
900+
"external-hook-capability": {
901+
enabled: true,
902+
},
903+
},
904+
},
905+
},
906+
expected: [],
907+
});
908+
});
909+
910+
it("blocks hook-capability plugins when explicitly denied", () => {
911+
expectStartupPluginIdsCase({
912+
config: {
913+
channels: {},
914+
plugins: {
915+
allow: ["external-hook-capability"],
916+
deny: ["external-hook-capability"],
917+
slots: { memory: "none" },
918+
entries: {
919+
"external-hook-capability": {
920+
enabled: true,
921+
},
922+
},
923+
},
924+
},
925+
expected: [],
926+
});
927+
});
928+
929+
it("loads explicit hook-policy plugins at startup", () => {
930+
expectStartupPluginIdsCase({
931+
config: {
932+
channels: {},
933+
plugins: {
934+
slots: { memory: "none" },
935+
entries: {
936+
browser: {
937+
enabled: false,
938+
},
939+
"external-hook-policy": {
940+
hooks: {
941+
allowConversationAccess: true,
942+
allowPromptInjection: true,
943+
},
944+
},
945+
},
946+
},
947+
},
948+
expected: ["external-hook-policy"],
949+
});
950+
});
951+
952+
it.each([
953+
["conversation access", { allowConversationAccess: true }],
954+
["prompt injection", { allowPromptInjection: true }],
955+
] as const)("loads hook-policy plugins with only %s enabled", (_name, hooks) => {
956+
expectStartupPluginIdsCase({
957+
config: {
958+
channels: {},
959+
plugins: {
960+
slots: { memory: "none" },
961+
entries: {
962+
browser: {
963+
enabled: false,
964+
},
965+
"external-hook-policy": {
966+
hooks,
967+
},
968+
},
969+
},
970+
},
971+
expected: ["external-hook-policy"],
972+
});
973+
});
974+
975+
it("keeps hook-policy plugins behind restrictive allowlists", () => {
976+
expectStartupPluginIdsCase({
977+
config: {
978+
channels: {},
979+
plugins: {
980+
allow: ["browser"],
981+
slots: { memory: "none" },
982+
entries: {
983+
browser: {
984+
enabled: false,
985+
},
986+
"external-hook-policy": {
987+
hooks: {
988+
allowPromptInjection: true,
989+
},
990+
},
991+
},
992+
},
993+
},
994+
expected: [],
995+
});
996+
});
997+
998+
it("does not let effective-only hook policy bypass the authored startup allowlist", () => {
999+
const activationSourceConfig = {
1000+
channels: {},
1001+
plugins: {
1002+
allow: ["browser"],
1003+
slots: { memory: "none" },
1004+
entries: {
1005+
browser: {
1006+
enabled: false,
1007+
},
1008+
},
1009+
},
1010+
} as OpenClawConfig;
1011+
const runtimeConfig = {
1012+
channels: {},
1013+
plugins: {
1014+
allow: ["browser", "external-hook-policy"],
1015+
slots: { memory: "none" },
1016+
entries: {
1017+
browser: {
1018+
enabled: false,
1019+
},
1020+
"external-hook-policy": {
1021+
hooks: {
1022+
allowPromptInjection: true,
1023+
},
1024+
},
1025+
},
1026+
},
1027+
} as OpenClawConfig;
1028+
1029+
expectStartupPluginIdsCase({
1030+
config: runtimeConfig,
1031+
activationSourceConfig,
1032+
expected: [],
1033+
});
1034+
});
1035+
8501036
it("starts bundled sidecars selected by root config activation paths", () => {
8511037
const rawConfig = {
8521038
browser: {

src/plugins/gateway-startup-plugin-ids.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export type GatewayStartupPluginPlan = {
3737
pluginIds: readonly string[];
3838
};
3939

40+
type NormalizedPluginsConfig = ReturnType<typeof normalizePluginsConfigWithRegistry>;
41+
4042
function isRecord(value: unknown): value is Record<string, unknown> {
4143
return Boolean(value && typeof value === "object" && !Array.isArray(value));
4244
}
@@ -282,6 +284,76 @@ function canStartConfiguredRootPlugin(params: {
282284
return true;
283285
}
284286

287+
function hasExplicitHookPolicyConfig(
288+
entry: NormalizedPluginsConfig["entries"][string] | undefined,
289+
): boolean {
290+
return (
291+
entry?.hooks?.allowConversationAccess === true || entry?.hooks?.allowPromptInjection === true
292+
);
293+
}
294+
295+
function hasHookRuntimeStartupIntent(params: {
296+
plugin: InstalledPluginIndexRecord;
297+
manifest: PluginManifestRecord | undefined;
298+
activationSourcePlugins: NormalizedPluginsConfig;
299+
}): boolean {
300+
if (params.manifest?.activation?.onCapabilities?.includes("hook")) {
301+
return true;
302+
}
303+
return hasExplicitHookPolicyConfig(
304+
params.activationSourcePlugins.entries[params.plugin.pluginId],
305+
);
306+
}
307+
308+
function canStartExplicitHookPlugin(params: {
309+
plugin: InstalledPluginIndexRecord;
310+
manifest: PluginManifestRecord | undefined;
311+
config: OpenClawConfig;
312+
pluginsConfig: NormalizedPluginsConfig;
313+
activationSource: {
314+
plugins: NormalizedPluginsConfig;
315+
rootConfig?: OpenClawConfig;
316+
};
317+
activationSourcePlugins: NormalizedPluginsConfig;
318+
}): boolean {
319+
const hasHookPolicyIntent = hasExplicitHookPolicyConfig(
320+
params.activationSourcePlugins.entries[params.plugin.pluginId],
321+
);
322+
if (
323+
!hasHookRuntimeStartupIntent({
324+
plugin: params.plugin,
325+
manifest: params.manifest,
326+
activationSourcePlugins: params.activationSourcePlugins,
327+
})
328+
) {
329+
return false;
330+
}
331+
if (!params.pluginsConfig.enabled || !params.activationSourcePlugins.enabled) {
332+
return false;
333+
}
334+
if (
335+
params.pluginsConfig.deny.includes(params.plugin.pluginId) ||
336+
params.activationSourcePlugins.deny.includes(params.plugin.pluginId)
337+
) {
338+
return false;
339+
}
340+
if (
341+
params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false ||
342+
params.activationSourcePlugins.entries[params.plugin.pluginId]?.enabled === false
343+
) {
344+
return false;
345+
}
346+
const activationState = resolveEffectivePluginActivationState({
347+
id: params.plugin.pluginId,
348+
origin: params.plugin.origin,
349+
config: params.pluginsConfig,
350+
rootConfig: params.config,
351+
enabledByDefault: params.plugin.enabledByDefault,
352+
activationSource: params.activationSource,
353+
});
354+
return activationState.enabled && (activationState.explicitlyEnabled || hasHookPolicyIntent);
355+
}
356+
285357
function canStartConfiguredChannelPlugin(params: {
286358
plugin: InstalledPluginIndexRecord;
287359
config: OpenClawConfig;
@@ -499,6 +571,18 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: {
499571
) {
500572
return true;
501573
}
574+
if (
575+
canStartExplicitHookPlugin({
576+
plugin,
577+
manifest,
578+
config: params.config,
579+
pluginsConfig,
580+
activationSource,
581+
activationSourcePlugins,
582+
})
583+
) {
584+
return true;
585+
}
502586
if (
503587
!shouldConsiderForGatewayStartup({
504588
plugin,

0 commit comments

Comments
 (0)