Skip to content

Commit e5314c0

Browse files
committed
fix: keep hook policy registry internal
1 parent 07ef0f0 commit e5314c0

7 files changed

Lines changed: 247 additions & 212 deletions

src/agents/agent-tools.before-tool-call.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ import {
3737
import type { SessionState } from "../logging/diagnostic-session-state.js";
3838
import { redactToolDetail } from "../logging/redact.js";
3939
import { createSubsystemLogger } from "../logging/subsystem.js";
40-
import { getGlobalHookRunner, getGlobalHookRunnerRegistry } from "../plugins/hook-runner-global.js";
40+
import { getGlobalHookRunnerRegistry } from "../plugins/hook-runner-global-state.js";
41+
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
4142
import { deriveToolParams } from "../plugins/host-tool-param-parsers.js";
4243
import { copyPluginToolMeta, getPluginToolMeta } from "../plugins/tools.js";
4344
import {

src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,22 @@ describe("runtime api guardrails", () => {
355355
}
356356
});
357357

358+
it("keeps the composed hook-runner registry internal", () => {
359+
const pluginRuntime = readFileSync(resolve(ROOT_DIR, "plugin-sdk/plugin-runtime.ts"), "utf8");
360+
const hookRunnerGlobal = readFileSync(
361+
resolve(ROOT_DIR, "plugins/hook-runner-global.ts"),
362+
"utf8",
363+
);
364+
const hookRegistryTypes = readFileSync(
365+
resolve(ROOT_DIR, "plugins/hook-registry.types.ts"),
366+
"utf8",
367+
);
368+
369+
expect(pluginRuntime).toContain('export * from "../plugins/hook-runner-global.js";');
370+
expect(hookRunnerGlobal).not.toContain("getGlobalHookRunnerRegistry");
371+
expect(hookRegistryTypes).not.toContain("trustedToolPolicies");
372+
});
373+
358374
it("keeps Slack's narrow runtime-setter entrypoint pinned to a single export", () => {
359375
// Regression for #69317. The bundled channel entry's runtime.specifier
360376
// now points at runtime-setter-api.ts. The whole point of that file is

src/plugins/hook-registry.types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Defines plugin hook registry entry and dispatch types.
22
import type { HookEntry } from "../hooks/types.js";
33
import type { PluginHookRegistration as TypedPluginHookRegistration } from "./hook-types.js";
4-
import type { PluginTrustedToolPolicyRegistryRegistration } from "./registry-types.js";
54

65
/** Legacy hook registration stored by the global hook runner registry. */
76
export type PluginLegacyHookRegistration = {
@@ -24,5 +23,4 @@ export type GlobalHookRunnerRegistry = HookRunnerRegistry & {
2423
id: string;
2524
status: "loaded" | "disabled" | "error";
2625
}>;
27-
trustedToolPolicies?: PluginTrustedToolPolicyRegistryRegistration[];
2826
};
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Internal state and composed-registry view for the global hook runner.
2+
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
3+
import type { GlobalHookRunnerRegistry } from "./hook-registry.types.js";
4+
import type { HookRunner } from "./hooks.js";
5+
import { isPluginRegistryRetired } from "./registry-lifecycle.js";
6+
import type {
7+
PluginRegistry,
8+
PluginTrustedToolPolicyRegistryRegistration,
9+
} from "./registry-types.js";
10+
import { collectLivePluginRegistries } from "./runtime.js";
11+
12+
type TrustedPolicyHookRunnerRegistry = GlobalHookRunnerRegistry & {
13+
trustedToolPolicies?: PluginTrustedToolPolicyRegistryRegistration[];
14+
};
15+
16+
export type HookRunnerGlobalState = {
17+
hookRunner: HookRunner | null;
18+
registry: TrustedPolicyHookRunnerRegistry | null;
19+
};
20+
21+
const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state");
22+
23+
export function getHookRunnerGlobalState(): HookRunnerGlobalState {
24+
return resolveGlobalSingleton<HookRunnerGlobalState>(hookRunnerGlobalStateKey, () => ({
25+
hookRunner: null,
26+
registry: null,
27+
}));
28+
}
29+
30+
function collectHookRegistrySources(
31+
lastInitialized: TrustedPolicyHookRunnerRegistry | null,
32+
): TrustedPolicyHookRunnerRegistry[] {
33+
const ordered: TrustedPolicyHookRunnerRegistry[] = [];
34+
const seen = new Set<TrustedPolicyHookRunnerRegistry>();
35+
const add = (registry: TrustedPolicyHookRunnerRegistry | null) => {
36+
if (!registry || seen.has(registry)) {
37+
return;
38+
}
39+
// Retired registries were superseded by a newer activation; dispatching
40+
// their hooks would resurrect stale config closures. Only lastInitialized
41+
// can be retired here (the live registries below are active/pinned, never
42+
// retired); SDK-supplied registries are not PluginRegistry and never match.
43+
if (isPluginRegistryRetired(registry as PluginRegistry)) {
44+
return;
45+
}
46+
seen.add(registry);
47+
ordered.push(registry);
48+
};
49+
// Precedence: the explicitly initialized registry wins so an SDK caller that
50+
// initializes an isolated registry stays authoritative; in the gateway it is
51+
// the same object as the active registry, so this just dedupes.
52+
add(lastInitialized);
53+
for (const registry of collectLivePluginRegistries()) {
54+
add(registry);
55+
}
56+
return ordered;
57+
}
58+
59+
function composeLiveHookRegistry(
60+
lastInitialized: TrustedPolicyHookRunnerRegistry | null,
61+
): TrustedPolicyHookRunnerRegistry {
62+
const sources = collectHookRegistrySources(lastInitialized);
63+
// One source registry owns a plugin's entire contribution (status + hooks),
64+
// so handlers never double-fire across registries and a plugin's hooks stay
65+
// paired with the status the inbound-claim path reads.
66+
const ownerSourceIndexByPluginId = new Map<string, number>();
67+
const claimOwner = (pluginId: string, index: number) => {
68+
if (!ownerSourceIndexByPluginId.has(pluginId)) {
69+
ownerSourceIndexByPluginId.set(pluginId, index);
70+
}
71+
};
72+
// pluginIds each source actually contributes a hook for, so ownership can
73+
// prefer a source that carries the plugin's hooks over a same-plugin record
74+
// that loaded without any (e.g. a setup-runtime channel load registers the
75+
// channel but not the plugin's api.on(...) hooks).
76+
const hookPluginIdsBySource = sources.map((registry) => {
77+
const ids = new Set<string>();
78+
for (const hook of registry.typedHooks) {
79+
ids.add(hook.pluginId);
80+
}
81+
for (const hook of registry.hooks) {
82+
ids.add(hook.pluginId);
83+
}
84+
return ids;
85+
});
86+
// Prefer the highest-precedence source where the plugin loaded AND actually
87+
// contributes a hook, so a loaded-but-hookless record (failed/disabled scoped
88+
// reload, or a setup-runtime channel load) cannot shadow a lower-precedence
89+
// registration that still carries a fail-closed tool-call gate.
90+
sources.forEach((registry, index) => {
91+
for (const plugin of registry.plugins) {
92+
if (plugin.status === "loaded" && hookPluginIdsBySource[index].has(plugin.id)) {
93+
claimOwner(plugin.id, index);
94+
}
95+
}
96+
});
97+
// Then a loaded record owns the plugin's status when no live source
98+
// contributes a hook for it, keeping status paired with a single owner.
99+
sources.forEach((registry, index) => {
100+
for (const plugin of registry.plugins) {
101+
if (plugin.status === "loaded") {
102+
claimOwner(plugin.id, index);
103+
}
104+
}
105+
});
106+
sources.forEach((registry, index) => {
107+
for (const plugin of registry.plugins) {
108+
claimOwner(plugin.id, index);
109+
}
110+
});
111+
// Defensive: claim any hook whose plugin record is absent from .plugins so a
112+
// malformed registry never silently drops a registered hook.
113+
sources.forEach((registry, index) => {
114+
for (const hook of registry.typedHooks) {
115+
claimOwner(hook.pluginId, index);
116+
}
117+
for (const hook of registry.hooks) {
118+
claimOwner(hook.pluginId, index);
119+
}
120+
});
121+
const policyOwnerSourceIndexByPluginId = new Map<string, number>();
122+
const claimPolicyOwner = (pluginId: string, index: number) => {
123+
if (!policyOwnerSourceIndexByPluginId.has(pluginId)) {
124+
policyOwnerSourceIndexByPluginId.set(pluginId, index);
125+
}
126+
};
127+
const trustedPolicyPluginIdsBySource = sources.map((registry) => {
128+
const ids = new Set<string>();
129+
for (const registration of registry.trustedToolPolicies ?? []) {
130+
ids.add(registration.pluginId);
131+
}
132+
return ids;
133+
});
134+
sources.forEach((registry, index) => {
135+
for (const plugin of registry.plugins) {
136+
if (plugin.status === "loaded" && trustedPolicyPluginIdsBySource[index].has(plugin.id)) {
137+
claimPolicyOwner(plugin.id, index);
138+
}
139+
}
140+
});
141+
sources.forEach((registry, index) => {
142+
for (const plugin of registry.plugins) {
143+
if (plugin.status === "loaded") {
144+
claimPolicyOwner(plugin.id, index);
145+
}
146+
}
147+
});
148+
sources.forEach((registry, index) => {
149+
for (const plugin of registry.plugins) {
150+
claimPolicyOwner(plugin.id, index);
151+
}
152+
});
153+
sources.forEach((registry, index) => {
154+
for (const registration of registry.trustedToolPolicies ?? []) {
155+
claimPolicyOwner(registration.pluginId, index);
156+
}
157+
});
158+
const trustedToolPolicies = sources
159+
.flatMap((registry, index) =>
160+
(registry.trustedToolPolicies ?? []).filter(
161+
(registration) => policyOwnerSourceIndexByPluginId.get(registration.pluginId) === index,
162+
),
163+
)
164+
// Preserve the trusted-policy tier contract across composed registries:
165+
// bundled policies run before installed policies, and same-tier entries
166+
// keep the source/plugin-load order selected above.
167+
.toSorted((left, right) => {
168+
const leftRank = left.origin === "bundled" ? 0 : 1;
169+
const rightRank = right.origin === "bundled" ? 0 : 1;
170+
return leftRank - rightRank;
171+
});
172+
return {
173+
hooks: sources.flatMap((registry, index) =>
174+
registry.hooks.filter((hook) => ownerSourceIndexByPluginId.get(hook.pluginId) === index),
175+
),
176+
typedHooks: sources.flatMap((registry, index) =>
177+
registry.typedHooks.filter((hook) => ownerSourceIndexByPluginId.get(hook.pluginId) === index),
178+
),
179+
plugins: sources.flatMap((registry, index) =>
180+
registry.plugins.filter((plugin) => ownerSourceIndexByPluginId.get(plugin.id) === index),
181+
),
182+
trustedToolPolicies,
183+
};
184+
}
185+
186+
export function createComposedHookRegistryFacade(
187+
state: HookRunnerGlobalState,
188+
): TrustedPolicyHookRunnerRegistry {
189+
// Live getters: createHookRunner reads these on every hasHooks/getHooksForName
190+
// call, so the runner always dispatches the current live registry set rather
191+
// than a snapshot captured at initialization. Composition is bounded by the
192+
// small live registry set and runs on hook-paced events, not tight loops.
193+
return {
194+
get hooks() {
195+
return composeLiveHookRegistry(state.registry).hooks;
196+
},
197+
get typedHooks() {
198+
return composeLiveHookRegistry(state.registry).typedHooks;
199+
},
200+
get plugins() {
201+
return composeLiveHookRegistry(state.registry).plugins;
202+
},
203+
get trustedToolPolicies() {
204+
return composeLiveHookRegistry(state.registry).trustedToolPolicies;
205+
},
206+
};
207+
}
208+
209+
/** Get the composed registry that backs global hook dispatch. */
210+
export function getGlobalHookRunnerRegistry(): TrustedPolicyHookRunnerRegistry | null {
211+
const state = getHookRunnerGlobalState();
212+
return state.registry ? createComposedHookRegistryFacade(state) : null;
213+
}

src/plugins/hook-runner-global.compose.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* loader.hook-runner-live-view.test.ts.
66
*/
77
import { afterEach, describe, expect, it, vi } from "vitest";
8+
import { getGlobalHookRunnerRegistry } from "./hook-runner-global-state.js";
89
import {
9-
getGlobalHookRunnerRegistry,
1010
getGlobalHookRunner,
1111
initializeGlobalHookRunner,
1212
resetGlobalHookRunner,

src/plugins/hook-runner-global.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ async function importHookRunnerGlobalModule() {
1313
return import("./hook-runner-global.js");
1414
}
1515

16+
async function importHookRunnerGlobalStateModule() {
17+
return import("./hook-runner-global-state.js");
18+
}
19+
1620
type HookRunnerGlobalModule = Awaited<ReturnType<typeof importHookRunnerGlobalModule>>;
1721
type HookRunner = NonNullable<ReturnType<HookRunnerGlobalModule["getGlobalHookRunner"]>>;
1822

@@ -114,8 +118,9 @@ describe("hook-runner-global", () => {
114118
true,
115119
);
116120
expect(mod.getGlobalPluginRegistry()).toBe(laterRegistry);
121+
const stateMod = await importHookRunnerGlobalStateModule();
117122
expect(
118-
mod
123+
stateMod
119124
.getGlobalHookRunnerRegistry()
120125
?.trustedToolPolicies?.map((registration) => [
121126
registration.pluginId,

0 commit comments

Comments
 (0)