mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 03:36:02 +00:00
* fix: keep trusted policies with hook registry * fix: compose trusted policies with live hooks * fix: preserve trusted policy order * test: update hook runner mock * fix: keep hook policy registry internal
180 lines
6.9 KiB
TypeScript
180 lines
6.9 KiB
TypeScript
/**
|
|
* Composition rules for the global hook runner's live registry view (#91918).
|
|
* These exercise the ownership/precedence/liveness decisions directly with
|
|
* mock registries, complementing the real-load kill-chain coverage in
|
|
* loader.hook-runner-live-view.test.ts.
|
|
*/
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { getGlobalHookRunnerRegistry } from "./hook-runner-global-state.js";
|
|
import {
|
|
getGlobalHookRunner,
|
|
initializeGlobalHookRunner,
|
|
resetGlobalHookRunner,
|
|
} from "./hook-runner-global.js";
|
|
import { addTestHook, createMockPluginRegistry } from "./hooks.test-helpers.js";
|
|
import type { PluginRegistry } from "./registry.js";
|
|
import {
|
|
pinActivePluginChannelRegistry,
|
|
resetPluginRuntimeStateForTest,
|
|
setActivePluginRegistry,
|
|
} from "./runtime.js";
|
|
import { createPluginRecord } from "./status.test-helpers.js";
|
|
|
|
function runner() {
|
|
const value = getGlobalHookRunner();
|
|
if (!value) {
|
|
throw new Error("Expected global hook runner");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
afterEach(() => {
|
|
resetGlobalHookRunner();
|
|
resetPluginRuntimeStateForTest();
|
|
});
|
|
|
|
describe("global hook runner composition (#91918)", () => {
|
|
it("prefers a loaded registration over a failed scoped reload of the same plugin", () => {
|
|
const boot = createMockPluginRegistry([
|
|
{ hookName: "before_tool_call", handler: vi.fn(), pluginId: "gate" },
|
|
]);
|
|
// Scoped reload where the gate plugin failed to register: record present,
|
|
// status not loaded, no hooks.
|
|
const scopedFailure = createMockPluginRegistry([]);
|
|
scopedFailure.plugins[0].id = "gate";
|
|
scopedFailure.plugins[0].status = "error";
|
|
|
|
setActivePluginRegistry(boot);
|
|
pinActivePluginChannelRegistry(boot);
|
|
initializeGlobalHookRunner(boot);
|
|
expect(runner().hasHooks("before_tool_call")).toBe(true);
|
|
|
|
setActivePluginRegistry(scopedFailure);
|
|
initializeGlobalHookRunner(scopedFailure);
|
|
// The pinned boot registry still owns the loaded gate, so the fail-closed
|
|
// tool-call hook is not shadowed by the errored scoped record.
|
|
expect(runner().hasHooks("before_tool_call")).toBe(true);
|
|
});
|
|
|
|
it("prefers a loaded source that carries the hook over a loaded-but-hookless record", () => {
|
|
// Pinned boot registry: plugin C loaded WITH a fail-closed tool-call gate.
|
|
const boot = createMockPluginRegistry([
|
|
{ hookName: "before_tool_call", handler: vi.fn(), pluginId: "C" },
|
|
]);
|
|
// Scoped reload where C is present and loaded but registered no hooks
|
|
// (e.g. a setup-runtime channel load registers the channel, not api.on).
|
|
const scopedHookless = createMockPluginRegistry([]);
|
|
scopedHookless.plugins[0].id = "C";
|
|
scopedHookless.plugins[0].status = "loaded";
|
|
|
|
pinActivePluginChannelRegistry(boot);
|
|
setActivePluginRegistry(scopedHookless);
|
|
initializeGlobalHookRunner(scopedHookless);
|
|
// The hookless scoped record is highest precedence but must not shadow the
|
|
// pinned registration that actually carries C's gate.
|
|
expect(runner().hasHooks("before_tool_call")).toBe(true);
|
|
});
|
|
|
|
it("keeps a pinned registry with zero channels visible to hook dispatch", () => {
|
|
const hookOnlyPinned = createMockPluginRegistry([
|
|
{ hookName: "subagent_ended", handler: vi.fn(), pluginId: "hooky" },
|
|
]);
|
|
const channelActive = createMockPluginRegistry([
|
|
{ hookName: "message_sent", handler: vi.fn(), pluginId: "chan" },
|
|
]);
|
|
// Give the active registry a channel so the channel-presentation selector
|
|
// would prefer it and evict the zero-channel pinned registry — the raw
|
|
// live-registry collector must keep the pinned one regardless.
|
|
(channelActive.channels as unknown[]).push({});
|
|
|
|
setActivePluginRegistry(channelActive);
|
|
pinActivePluginChannelRegistry(hookOnlyPinned);
|
|
initializeGlobalHookRunner(channelActive);
|
|
|
|
expect(runner().hasHooks("subagent_ended")).toBe(true);
|
|
expect(runner().hasHooks("message_sent")).toBe(true);
|
|
});
|
|
|
|
it("keeps bundled trusted policies before installed policies across live registries", () => {
|
|
const pinnedBundled = createMockPluginRegistry([
|
|
{ hookName: "before_tool_call", handler: vi.fn(), pluginId: "bundled-policy" },
|
|
]);
|
|
pinnedBundled.plugins = [createPluginRecord({ id: "bundled-policy", origin: "bundled" })];
|
|
pinnedBundled.trustedToolPolicies = [
|
|
{
|
|
pluginId: "bundled-policy",
|
|
pluginName: "Bundled Policy",
|
|
origin: "bundled",
|
|
source: "test",
|
|
policy: {
|
|
id: "bundled-first",
|
|
description: "bundled policy",
|
|
evaluate: () => undefined,
|
|
},
|
|
},
|
|
];
|
|
const activeInstalled = createMockPluginRegistry([]);
|
|
activeInstalled.plugins = [createPluginRecord({ id: "installed-policy", origin: "workspace" })];
|
|
activeInstalled.trustedToolPolicies = [
|
|
{
|
|
pluginId: "installed-policy",
|
|
pluginName: "Installed Policy",
|
|
origin: "workspace",
|
|
source: "test",
|
|
policy: {
|
|
id: "installed-second",
|
|
description: "installed policy",
|
|
evaluate: () => undefined,
|
|
},
|
|
},
|
|
];
|
|
|
|
pinActivePluginChannelRegistry(pinnedBundled);
|
|
setActivePluginRegistry(activeInstalled);
|
|
initializeGlobalHookRunner(activeInstalled);
|
|
|
|
expect(
|
|
getGlobalHookRunnerRegistry()?.trustedToolPolicies?.map((registration) => [
|
|
registration.origin,
|
|
registration.policy.id,
|
|
]),
|
|
).toEqual([
|
|
["bundled", "bundled-first"],
|
|
["workspace", "installed-second"],
|
|
]);
|
|
});
|
|
|
|
it("lets an explicitly initialized registry win ownership over the active registry", () => {
|
|
const activeRegistry = createMockPluginRegistry([
|
|
{ hookName: "message_received", handler: vi.fn(), pluginId: "foo" },
|
|
]);
|
|
const sdkRegistry = createMockPluginRegistry([
|
|
{ hookName: "message_sent", handler: vi.fn(), pluginId: "foo" },
|
|
]);
|
|
|
|
setActivePluginRegistry(activeRegistry);
|
|
initializeGlobalHookRunner(sdkRegistry);
|
|
|
|
// Last-initialized highest precedence: the SDK registry owns plugin "foo",
|
|
// so its hook dispatches and the active registry's "foo" hook is shadowed.
|
|
expect(runner().hasHooks("message_sent")).toBe(true);
|
|
expect(runner().hasHooks("message_received")).toBe(false);
|
|
});
|
|
|
|
it("dispatches hooks pushed into a registry after initialization", () => {
|
|
const registry: PluginRegistry = createMockPluginRegistry([
|
|
{ hookName: "message_received", handler: vi.fn(), pluginId: "p" },
|
|
]);
|
|
|
|
setActivePluginRegistry(registry);
|
|
initializeGlobalHookRunner(registry);
|
|
// Read once so any internal caching would have settled.
|
|
expect(runner().hasHooks("message_received")).toBe(true);
|
|
expect(runner().hasHooks("message_sent")).toBe(false);
|
|
|
|
addTestHook({ registry, pluginId: "p", hookName: "message_sent", handler: vi.fn() });
|
|
// Live composition: the late registration is visible without re-init.
|
|
expect(runner().hasHooks("message_sent")).toBe(true);
|
|
});
|
|
});
|