mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 03:26:04 +00:00
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.
116 lines
4.3 KiB
TypeScript
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");
|
|
});
|
|
});
|