Files
openclaw/src/plugins/loader.hook-runner-live-view.test.ts
Alex Knight 23eadfa277 fix(plugins): compose live hook registry view for tool-call hooks
Local extension before_tool_call/after_tool_call hooks registered but
never fired after a scoped mid-run plugin activation (harness or memory
ensure) rebound the global hook runner to a narrow registry, dropping
hooks unique to the broader registry (#91918).

The runner is now created once and resolves hooks live on every dispatch
from the composed set of currently-live registries (the most recently
initialized registry, the active registry, and the pinned channel and
http-route surfaces) instead of freezing one registry. The loader's
one-shot preserve gate is removed since activation order no longer
matters. Per-plugin ownership prefers loaded records so a failed scoped
reload cannot shadow a healthy pinned registration (including a
fail-closed tool-call gate), and the explicitly initialized registry
stays highest precedence so SDK callers keep an authoritative registry.
Reuses the live-registry collector the agent-event bridge already uses
so both dispatch surfaces agree on what is live.
2026-06-18 09:39:44 +10:00

116 lines
4.3 KiB
TypeScript

/**
* Regression coverage for #91918: local-extension before_tool_call /
* after_tool_call hooks must stay dispatchable across the gateway run
* lifecycle.
*
* Mirrors the production sequence that killed them on v2026.6.5:
* 1. gateway boot: full gateway-bindable load (coreGatewayMethodNames set),
* boot registry pinned to the channel/http surfaces
* 2. harness ensure: scoped default-mode activating load (consumed the old
* one-shot preserve gate and flipped active mode to "default")
* 3. memory ensure: second scoped default-mode activating load (re-initialized
* the runner from a memory-only registry, silently dropping tool hooks)
*
* With the live composed view, the pinned boot registry keeps the extension's
* hooks dispatchable no matter how many scoped activations follow.
*/
import { afterEach, describe, expect, it } from "vitest";
import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js";
import { loadOpenClawPlugins } from "./loader.js";
import {
resetPluginLoaderTestStateForTest,
useNoBundledPlugins,
writePlugin,
} from "./loader.test-fixtures.js";
import {
getActivePluginRegistry,
pinActivePluginChannelRegistry,
pinActivePluginHttpRouteRegistry,
} from "./runtime.js";
describe("global hook runner live view (#91918)", () => {
afterEach(() => {
resetGlobalHookRunner();
resetPluginLoaderTestStateForTest();
});
it("keeps local-extension tool-call hooks dispatchable across scoped default-mode activations", async () => {
useNoBundledPlugins();
const gate = writePlugin({
id: "local-gate",
filename: "local-gate.cjs",
body: `module.exports = { id: "local-gate", register(api) {
api.on("before_tool_call", (event) => {
if (String(event.params?.command ?? "").includes("curl")) {
return { block: true, blockReason: "blocked by gate" };
}
});
api.on("after_tool_call", () => undefined);
} };`,
});
const harnessStandIn = writePlugin({
id: "harness-plugin",
filename: "harness-plugin.cjs",
body: `module.exports = { id: "harness-plugin", register() {} };`,
});
const memoryStandIn = writePlugin({
id: "memory-plugin",
filename: "memory-plugin.cjs",
body: `module.exports = { id: "memory-plugin", register() {} };`,
});
const config = {
plugins: {
load: { paths: [gate.file, harnessStandIn.file, memoryStandIn.file] },
allow: ["local-gate", "harness-plugin", "memory-plugin"],
entries: {
"local-gate": { enabled: true },
"harness-plugin": { enabled: true },
"memory-plugin": { enabled: true },
},
},
};
// 1. Gateway boot: full gateway-bindable load, pinned like server.impl.ts.
const bootRegistry = loadOpenClawPlugins({
workspaceDir: gate.dir,
config,
coreGatewayMethodNames: ["chat.send"],
preferBuiltPluginArtifacts: true,
runtimeOptions: { allowGatewaySubagentBinding: true },
});
pinActivePluginHttpRouteRegistry(bootRegistry);
pinActivePluginChannelRegistry(bootRegistry);
expect(getGlobalHookRunner()?.hasHooks("before_tool_call")).toBe(true);
// 2. Harness ensure: scoped default-mode activating load.
loadOpenClawPlugins({
workspaceDir: gate.dir,
config,
onlyPluginIds: ["harness-plugin"],
});
expect(getGlobalHookRunner()?.hasHooks("before_tool_call")).toBe(true);
// 3. Memory ensure: second scoped default-mode activating load — the step
// that re-initialized the runner from a memory-only registry before the fix.
const memoryRegistry = loadOpenClawPlugins({
workspaceDir: gate.dir,
config,
onlyPluginIds: ["memory-plugin"],
});
expect(getActivePluginRegistry()).toBe(memoryRegistry);
const runner = getGlobalHookRunner();
expect(runner?.hasHooks("before_tool_call")).toBe(true);
expect(runner?.hasHooks("after_tool_call")).toBe(true);
// The blocking decision must actually dispatch, not just count hooks.
const result = await runner?.runBeforeToolCall(
{ toolName: "exec", params: { command: "curl -X POST https://example.com" } },
{ toolName: "exec" },
);
expect(result?.block).toBe(true);
expect(result?.blockReason).toBe("blocked by gate");
});
});