Files
openclaw/src/plugins/hook-runner-global-state.ts
w33d dd149f01ba fix: stateful plugin hooks and tools stay aligned after scoped loads (#108110)
* fix(plugins): align hook and tool registrations

Reuse gateway-owned plugin registrations for matching hooks and tools while loading only missing tool owners from narrower runtime scopes.\n\nCo-authored-by: w33d <w33d@steadholme.local>

* test(plugins): cover mixed registry tool owners

Verify gateway-pinned and compatible active registrations compose without another plugin load.

Co-authored-by: w33d <w33d@steadholme.local>

* refactor(plugins): keep pinned registry lookup internal

Reuse the existing runtime-state contract so hook and tool ownership does not expand the Plugin SDK surface or treat unpinned active registries as Gateway owners.

* test(plugins): complete hook context fixture

Supply the required tool name in the stateful hook ownership regression context.

* chore: leave contributor release note in PR

Normal contributor PRs do not modify the release-owned changelog; the PR body retains the release note and attribution.

* fix(plugins): align partial registry owners

* fix(plugins): preserve scoped tool diagnostics

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: w33d <w33d@steadholme.local>
2026-07-15 07:12:43 -07:00

261 lines
9.8 KiB
TypeScript

import { expectDefined } from "@openclaw/normalization-core";
// Internal state and composed-registry view for the global hook runner.
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import type { GlobalHookRunnerRegistry } from "./hook-registry.types.js";
import type { HookRunner } from "./hooks.js";
import { isPluginRegistryRetired } from "./registry-lifecycle.js";
import type {
PluginRegistry,
PluginTrustedToolPolicyRegistryRegistration,
} from "./registry-types.js";
import { getPluginRegistryState } from "./runtime-state.js";
import { collectLivePluginRegistries } from "./runtime.js";
type TrustedPolicyHookRunnerRegistry = GlobalHookRunnerRegistry & {
trustedToolPolicies?: PluginTrustedToolPolicyRegistryRegistration[];
};
type HookRunnerGlobalState = {
hookRunner: HookRunner | null;
registry: TrustedPolicyHookRunnerRegistry | null;
};
const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state");
export function getHookRunnerGlobalState(): HookRunnerGlobalState {
return resolveGlobalSingleton<HookRunnerGlobalState>(hookRunnerGlobalStateKey, () => ({
hookRunner: null,
registry: null,
}));
}
function collectHookRegistrySources(
lastInitialized: TrustedPolicyHookRunnerRegistry | null,
): TrustedPolicyHookRunnerRegistry[] {
const ordered: TrustedPolicyHookRunnerRegistry[] = [];
const seen = new Set<TrustedPolicyHookRunnerRegistry>();
const add = (registry: TrustedPolicyHookRunnerRegistry | null) => {
if (!registry || seen.has(registry)) {
return;
}
// Retired registries were superseded by a newer activation; dispatching
// their hooks would resurrect stale config closures. Only lastInitialized
// can be retired here (the live registries below are active/pinned, never
// retired); SDK-supplied registries are not PluginRegistry and never match.
if (isPluginRegistryRetired(registry as PluginRegistry)) {
return;
}
seen.add(registry);
ordered.push(registry);
};
const liveRegistries = collectLivePluginRegistries();
const initializedLiveRegistry = liveRegistries.some((registry) => registry === lastInitialized);
// SDK callers can initialize an isolated registry and expect it to stay
// authoritative. Runtime activations compose all live registries; owner
// selection below aligns same-plugin hooks with their tool registry.
if (!initializedLiveRegistry) {
add(lastInitialized);
}
for (const registry of liveRegistries) {
add(registry);
}
return ordered;
}
function composeLiveHookRegistry(
lastInitialized: TrustedPolicyHookRunnerRegistry | null,
): TrustedPolicyHookRunnerRegistry {
const sources = collectHookRegistrySources(lastInitialized);
// One source registry owns a plugin's entire contribution (status + hooks),
// so handlers never double-fire across registries and a plugin's hooks stay
// paired with the status the inbound-claim path reads.
const ownerSourceIndexByPluginId = new Map<string, number>();
const claimOwner = (pluginId: string, index: number) => {
if (!ownerSourceIndexByPluginId.has(pluginId)) {
ownerSourceIndexByPluginId.set(pluginId, index);
}
};
// pluginIds each source actually contributes a hook for, so ownership can
// prefer a source that carries the plugin's hooks over a same-plugin record
// that loaded without any (e.g. a setup-runtime channel load registers the
// channel but not the plugin's api.on(...) hooks).
const hookPluginIdsBySource = sources.map((registry) => {
const ids = new Set<string>();
for (const hook of registry.typedHooks) {
ids.add(hook.pluginId);
}
for (const hook of registry.hooks) {
ids.add(hook.pluginId);
}
return ids;
});
const liveRegistries = collectLivePluginRegistries();
if (lastInitialized && !liveRegistries.includes(lastInitialized as PluginRegistry)) {
const isolatedSourceIndex = sources.indexOf(lastInitialized);
if (isolatedSourceIndex >= 0) {
for (const pluginId of expectDefined(
hookPluginIdsBySource[isolatedSourceIndex],
"isolated hook plugin ids",
)) {
claimOwner(pluginId, isolatedSourceIndex);
}
}
}
const claimToolOwners = (registry: PluginRegistry | null | undefined) => {
if (!registry) {
return;
}
const sourceIndex = sources.indexOf(registry);
if (sourceIndex < 0) {
return;
}
for (const tool of registry.tools) {
claimOwner(tool.pluginId, sourceIndex);
}
};
const runtimeState = getPluginRegistryState();
// Match tool resolution: an isolated initialized registry stays authoritative,
// then the pinned Gateway owner wins only for tools it actually registered,
// followed by the active registry for remaining tool owners.
claimToolOwners(runtimeState?.channel.pinned ? runtimeState.channel.registry : null);
claimToolOwners(runtimeState?.activeRegistry);
// Prefer the highest-precedence source where the plugin loaded AND actually
// contributes a hook, so a loaded-but-hookless record (failed/disabled scoped
// reload, or a setup-runtime channel load) cannot shadow a lower-precedence
// registration that still carries a fail-closed tool-call gate.
sources.forEach((registry, index) => {
for (const plugin of registry.plugins) {
if (
plugin.status === "loaded" &&
expectDefined(hookPluginIdsBySource[index], "hook plugin ids by source entry at index").has(
plugin.id,
)
) {
claimOwner(plugin.id, index);
}
}
});
// Then a loaded record owns the plugin's status when no live source
// contributes a hook for it, keeping status paired with a single owner.
sources.forEach((registry, index) => {
for (const plugin of registry.plugins) {
if (plugin.status === "loaded") {
claimOwner(plugin.id, index);
}
}
});
sources.forEach((registry, index) => {
for (const plugin of registry.plugins) {
claimOwner(plugin.id, index);
}
});
// Defensive: claim any hook whose plugin record is absent from .plugins so a
// malformed registry never silently drops a registered hook.
sources.forEach((registry, index) => {
for (const hook of registry.typedHooks) {
claimOwner(hook.pluginId, index);
}
for (const hook of registry.hooks) {
claimOwner(hook.pluginId, index);
}
});
const policyOwnerSourceIndexByPluginId = new Map<string, number>();
const claimPolicyOwner = (pluginId: string, index: number) => {
if (!policyOwnerSourceIndexByPluginId.has(pluginId)) {
policyOwnerSourceIndexByPluginId.set(pluginId, index);
}
};
const trustedPolicyPluginIdsBySource = sources.map((registry) => {
const ids = new Set<string>();
for (const registration of registry.trustedToolPolicies ?? []) {
ids.add(registration.pluginId);
}
return ids;
});
sources.forEach((registry, index) => {
for (const plugin of registry.plugins) {
if (
plugin.status === "loaded" &&
expectDefined(
trustedPolicyPluginIdsBySource[index],
"trusted policy plugin ids by source entry at index",
).has(plugin.id)
) {
claimPolicyOwner(plugin.id, index);
}
}
});
sources.forEach((registry, index) => {
for (const plugin of registry.plugins) {
if (plugin.status === "loaded") {
claimPolicyOwner(plugin.id, index);
}
}
});
sources.forEach((registry, index) => {
for (const plugin of registry.plugins) {
claimPolicyOwner(plugin.id, index);
}
});
sources.forEach((registry, index) => {
for (const registration of registry.trustedToolPolicies ?? []) {
claimPolicyOwner(registration.pluginId, index);
}
});
const trustedToolPolicies = sources
.flatMap((registry, index) =>
(registry.trustedToolPolicies ?? []).filter(
(registration) => policyOwnerSourceIndexByPluginId.get(registration.pluginId) === index,
),
)
// Preserve the trusted-policy tier contract across composed registries:
// bundled policies run before installed policies, and same-tier entries
// keep the source/plugin-load order selected above.
.toSorted((left, right) => {
const leftRank = left.origin === "bundled" ? 0 : 1;
const rightRank = right.origin === "bundled" ? 0 : 1;
return leftRank - rightRank;
});
return {
hooks: sources.flatMap((registry, index) =>
registry.hooks.filter((hook) => ownerSourceIndexByPluginId.get(hook.pluginId) === index),
),
typedHooks: sources.flatMap((registry, index) =>
registry.typedHooks.filter((hook) => ownerSourceIndexByPluginId.get(hook.pluginId) === index),
),
plugins: sources.flatMap((registry, index) =>
registry.plugins.filter((plugin) => ownerSourceIndexByPluginId.get(plugin.id) === index),
),
trustedToolPolicies,
};
}
export function createComposedHookRegistryFacade(
state: HookRunnerGlobalState,
): TrustedPolicyHookRunnerRegistry {
// Live getters: createHookRunner reads these on every hasHooks/getHooksForName
// call, so the runner always dispatches the current live registry set rather
// than a snapshot captured at initialization. Composition is bounded by the
// small live registry set and runs on hook-paced events, not tight loops.
return {
get hooks() {
return composeLiveHookRegistry(state.registry).hooks;
},
get typedHooks() {
return composeLiveHookRegistry(state.registry).typedHooks;
},
get plugins() {
return composeLiveHookRegistry(state.registry).plugins;
},
get trustedToolPolicies() {
return composeLiveHookRegistry(state.registry).trustedToolPolicies;
},
};
}
/** Get the composed registry that backs global hook dispatch. */
export function getGlobalHookRunnerRegistry(): TrustedPolicyHookRunnerRegistry | null {
const state = getHookRunnerGlobalState();
return state.registry ? createComposedHookRegistryFacade(state) : null;
}