Skip to content

Commit 80707db

Browse files
authored
fix(plugins): bound hook execution and retire stale registrations (openclaw#115695)
* fix(plugins): bound hooks and own legacy registrations * docs(hooks): clarify internal handler ownership * fix(cli): retain message hook shutdown deadline
1 parent 0f9c702 commit 80707db

11 files changed

Lines changed: 329 additions & 40 deletions

docs/automation/hooks.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ OpenClaw has several extension surfaces that look similar but solve different pr
2727

2828
Use internal hooks when you want automation that behaves like a small installed integration. Use typed plugin hooks when you need runtime lifecycle control.
2929

30+
Internal hook handlers are request/event handlers. They must not own long-lived timers, watchers, sockets, or clients; plugins should register a service or use the typed `gateway_start` / `gateway_stop` lifecycle instead.
31+
3032
## Quick start
3133

3234
```bash

docs/plugins/hooks.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ receive a cancellation signal. The hook dispatch can release its Gateway
9393
admission while that plugin work is still in progress. Plugins that own
9494
long-running work must provide their own cancellation and shutdown lifecycle.
9595

96+
Policy hooks `before_tool_call` and `before_install` use a 15-second default per
97+
handler. A timeout fails closed: the tool call or installation is rejected
98+
instead of continuing without a policy decision.
99+
100+
`gateway_stop` uses a five-second default per handler. Timed-out handlers are
101+
logged and shutdown continues so plugin cleanup cannot consume the Gateway
102+
process watchdog.
103+
96104
Outbound modifying hooks `message_sending` and `reply_payload_sending` use a
97105
15-second default per handler. If one times out, OpenClaw logs the plugin error
98106
and continues with the latest payload so the serialized delivery lane can

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ async function expectGlobalRunnerState(expected: { hasRunner: boolean; registry?
4040
}
4141

4242
afterEach(async () => {
43+
vi.useRealTimers();
4344
const mod = await importHookRunnerGlobalModule();
4445
mod.resetGlobalHookRunner();
4546
setActivePluginRegistry(createEmptyPluginRegistry());
@@ -131,4 +132,101 @@ describe("hook-runner-global", () => {
131132
releasePinnedPluginChannelRegistry(gatewayRegistry);
132133
}
133134
});
135+
136+
it.each([
137+
{
138+
hookName: "before_tool_call" as const,
139+
run: (runner: HookRunner) =>
140+
runner.runBeforeToolCall({ toolName: "read", params: {} }, { toolName: "read" }),
141+
},
142+
{
143+
hookName: "before_install" as const,
144+
run: (runner: HookRunner) =>
145+
runner.runBeforeInstall(
146+
{
147+
targetName: "demo",
148+
targetType: "plugin",
149+
sourcePath: "/tmp/demo",
150+
sourcePathKind: "directory",
151+
origin: "local",
152+
request: { kind: "plugin-dir", mode: "install" },
153+
builtinScan: {
154+
status: "ok",
155+
scannedFiles: 0,
156+
critical: 0,
157+
warn: 0,
158+
info: 0,
159+
findings: [],
160+
},
161+
},
162+
{ origin: "local", targetType: "plugin", requestKind: "plugin-dir" },
163+
),
164+
},
165+
])("fails closed when a default-bounded $hookName handler hangs", async ({ hookName, run }) => {
166+
vi.useFakeTimers();
167+
let releaseHandler: (() => void) | undefined;
168+
const registry = createMockPluginRegistry([
169+
{
170+
hookName,
171+
pluginId: "hanging-policy",
172+
handler: () =>
173+
new Promise<void>((resolve) => {
174+
releaseHandler = resolve;
175+
}),
176+
},
177+
]);
178+
const mod = await importHookRunnerGlobalModule();
179+
setActivePluginRegistry(registry);
180+
mod.initializeGlobalHookRunner(registry);
181+
const pending = run(expectGlobalHookRunner(mod.getGlobalHookRunner()));
182+
183+
try {
184+
expect(vi.getTimerCount()).toBeGreaterThan(0);
185+
const rejection = expect(pending).rejects.toThrow(
186+
`${hookName} handler from hanging-policy failed: timed out after 15000ms`,
187+
);
188+
await vi.advanceTimersByTimeAsync(15_000);
189+
await rejection;
190+
} finally {
191+
releaseHandler?.();
192+
await pending.catch(() => undefined);
193+
}
194+
});
195+
196+
it("bounds gateway_stop handlers and lets shutdown continue", async () => {
197+
vi.useFakeTimers();
198+
let releaseHandler: (() => void) | undefined;
199+
const registry = createMockPluginRegistry([
200+
{
201+
hookName: "gateway_stop",
202+
pluginId: "hanging-shutdown",
203+
handler: () =>
204+
new Promise<void>((resolve) => {
205+
releaseHandler = resolve;
206+
}),
207+
},
208+
]);
209+
const mod = await importHookRunnerGlobalModule();
210+
setActivePluginRegistry(registry);
211+
mod.initializeGlobalHookRunner(registry);
212+
const pending = mod.runGlobalGatewayStopSafely({
213+
event: { reason: "test shutdown" },
214+
ctx: {},
215+
});
216+
217+
try {
218+
expect(vi.getTimerCount()).toBeGreaterThan(0);
219+
let settled = false;
220+
void pending.then(() => {
221+
settled = true;
222+
});
223+
await vi.advanceTimersByTimeAsync(4_999);
224+
expect(settled).toBe(false);
225+
await vi.advanceTimersByTimeAsync(1);
226+
await expect(pending).resolves.toBeUndefined();
227+
} finally {
228+
releaseHandler?.();
229+
await pending;
230+
}
231+
});
134232
});

src/plugins/hooks.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,16 @@ const DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, numbe
156156
after_compaction: 30_000,
157157
skill_changed: 30_000,
158158
skill_proposal_changed: 30_000,
159+
// Shutdown hooks share the Gateway's five-second teardown budget. They fail
160+
// open after logging so one plugin cannot consume the process watchdog.
161+
gateway_stop: 5_000,
159162
};
160163
const DEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, number>> = {
161164
before_agent_run: 15_000,
165+
// Policy hooks fail closed in the global runner. A bounded timeout turns a
166+
// stalled policy process into a denial instead of freezing the operation.
167+
before_install: 15_000,
168+
before_tool_call: 15_000,
162169
// Terminal finalization hooks sit on the runner's completion path. A hung
163170
// handler must not freeze final delivery or keep compaction retry recovery
164171
// unresolved; timeout fail-opens with the original final answer.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
registerInternalHook,
3+
unregisterInternalHook,
4+
type InternalHookHandler,
5+
} from "../hooks/internal-hooks.js";
6+
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
7+
8+
export type LegacyPluginInternalHookRegistration = {
9+
event: string;
10+
handler: InternalHookHandler;
11+
};
12+
13+
export type LegacyPluginInternalHookState = Map<string, LegacyPluginInternalHookRegistration[]>;
14+
15+
const LEGACY_PLUGIN_INTERNAL_HOOKS_KEY = Symbol.for("openclaw.activePluginHookRegistrations");
16+
const registrations = resolveGlobalSingleton<LegacyPluginInternalHookState>(
17+
LEGACY_PLUGIN_INTERNAL_HOOKS_KEY,
18+
() => new Map(),
19+
);
20+
21+
function cloneRegistrations(
22+
values: readonly LegacyPluginInternalHookRegistration[],
23+
): LegacyPluginInternalHookRegistration[] {
24+
return values.map((registration) => ({ ...registration }));
25+
}
26+
27+
export function replaceLegacyPluginInternalHook(
28+
name: string,
29+
nextRegistrations: readonly LegacyPluginInternalHookRegistration[],
30+
): LegacyPluginInternalHookRegistration[] {
31+
const previousRegistrations = cloneRegistrations(registrations.get(name) ?? []);
32+
for (const registration of registrations.get(name) ?? []) {
33+
unregisterInternalHook(registration.event, registration.handler);
34+
}
35+
for (const registration of nextRegistrations) {
36+
registerInternalHook(registration.event, registration.handler);
37+
}
38+
if (nextRegistrations.length === 0) {
39+
registrations.delete(name);
40+
} else {
41+
registrations.set(name, cloneRegistrations(nextRegistrations));
42+
}
43+
return previousRegistrations;
44+
}
45+
46+
export function clearLegacyPluginInternalHooks(): void {
47+
for (const name of registrations.keys()) {
48+
replaceLegacyPluginInternalHook(name, []);
49+
}
50+
}
51+
52+
export function snapshotLegacyPluginInternalHooks(): LegacyPluginInternalHookState {
53+
return new Map(
54+
[...registrations].map(([name, hookRegistrations]) => [
55+
name,
56+
cloneRegistrations(hookRegistrations),
57+
]),
58+
);
59+
}
60+
61+
export function restoreLegacyPluginInternalHooks(state: LegacyPluginInternalHookState): void {
62+
clearLegacyPluginInternalHooks();
63+
for (const [name, hookRegistrations] of state) {
64+
replaceLegacyPluginInternalHook(name, hookRegistrations);
65+
}
66+
}

src/plugins/loader-shared.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { clearEmbeddingProviders } from "./embedding-providers.js";
2525
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
2626
import { collectPluginManifestCompatCodes } from "./installed-plugin-index-record-builder.js";
2727
import { clearPluginInteractiveHandlers } from "./interactive-registry.js";
28+
import { clearLegacyPluginInternalHooks } from "./legacy-internal-hook-state.js";
2829
import { createPluginRecord } from "./loader-records.js";
2930
import type { PluginLoadOptions, PluginRuntimeSubagentMode } from "./loader-types.js";
3031
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
@@ -172,6 +173,10 @@ export function clearActivatedPluginRuntimeState(): void {
172173
clearCompactionProviders();
173174
clearDetachedTaskLifecycleRuntimeRegistration();
174175
clearPluginInteractiveHandlers();
176+
// Legacy api.registerHook callbacks are process-global compatibility state.
177+
// Retire them with the active registry so disabled or removed plugins cannot
178+
// keep running.
179+
clearLegacyPluginInternalHooks();
175180
clearEmbeddingProviders();
176181
clearMemoryEmbeddingProviders();
177182
clearMemoryPluginState();

src/plugins/loader.base.test-utils.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1789,6 +1789,13 @@ describe("loadOpenClawPlugins", () => {
17891789
api.registerAgentToolResultMiddleware(() => undefined, {
17901790
runtimes: ["openclaw"],
17911791
});
1792+
api.registerHook(
1793+
"gateway:startup",
1794+
(event) => {
1795+
event.messages.push("rollback-hook-fired");
1796+
},
1797+
{ name: "reload-rollback-hook" },
1798+
);
17921799
api.on("gateway_stop", async () => {});
17931800
},
17941801
};`,
@@ -1811,7 +1818,7 @@ describe("loadOpenClawPlugins", () => {
18111818
};
18121819

18131820
const activeRegistry = loadOpenClawPlugins(loadOptions);
1814-
const expectRegistrationsIntact = () => {
1821+
const expectRegistrationsIntact = async () => {
18151822
expect(getActivePluginRegistry()).toBe(activeRegistry);
18161823
expect(getRegisteredAgentHarness("codex")).toBeDefined();
18171824
expect(getPluginCommandSpecs().map((entry) => entry.name)).toEqual(["pair"]);
@@ -1820,8 +1827,11 @@ describe("loadOpenClawPlugins", () => {
18201827
]);
18211828
expect(activeRegistry.agentToolResultMiddlewares).toHaveLength(1);
18221829
expect(activeRegistry.typedHooks.map((entry) => entry.hookName)).toEqual(["gateway_stop"]);
1830+
const event = createInternalHookEvent("gateway", "startup", "gateway:startup");
1831+
await triggerInternalHook(event);
1832+
expect(event.messages).toEqual(["rollback-hook-fired"]);
18231833
};
1824-
expectRegistrationsIntact();
1834+
await expectRegistrationsIntact();
18251835

18261836
const manifestRegistry = await import("./manifest-registry.js");
18271837
const manifestSpy = vi
@@ -1832,7 +1842,7 @@ describe("loadOpenClawPlugins", () => {
18321842

18331843
try {
18341844
expect(() => loadOpenClawPlugins(loadOptions)).toThrow("corrupt plugin manifest");
1835-
expectRegistrationsIntact();
1845+
await expectRegistrationsIntact();
18361846
} finally {
18371847
manifestSpy.mockRestore();
18381848
}
@@ -1858,7 +1868,7 @@ describe("loadOpenClawPlugins", () => {
18581868
onlyPluginIds: ["reload-rollback", "reload-rollback-failure"],
18591869
}),
18601870
).toThrow("plugin load failed: reload-rollback-failure: Error: register failed");
1861-
expectRegistrationsIntact();
1871+
await expectRegistrationsIntact();
18621872
});
18631873

18641874
it("rejects malformed plugin agent harness registrations", () => {
@@ -1980,6 +1990,72 @@ describe("loadOpenClawPlugins", () => {
19801990
clearInternalHooks();
19811991
});
19821992

1993+
it.each(["disabled", "removed"] as const)(
1994+
"clears legacy internal hooks when their plugin is %s",
1995+
async (nextState) => {
1996+
useNoBundledPlugins();
1997+
const plugin = writePlugin({
1998+
id: "internal-hook-lifecycle",
1999+
filename: "internal-hook-lifecycle.cjs",
2000+
body: `module.exports = {
2001+
id: "internal-hook-lifecycle",
2002+
register(api) {
2003+
api.registerHook(
2004+
"gateway:startup",
2005+
(event) => {
2006+
event.messages.push("legacy-hook-fired");
2007+
},
2008+
{ name: "legacy-lifecycle-hook" },
2009+
);
2010+
},
2011+
};`,
2012+
});
2013+
2014+
clearInternalHooks();
2015+
loadOpenClawPlugins({
2016+
cache: false,
2017+
workspaceDir: plugin.dir,
2018+
config: {
2019+
plugins: {
2020+
load: { paths: [plugin.file] },
2021+
allow: ["internal-hook-lifecycle"],
2022+
},
2023+
},
2024+
});
2025+
2026+
const activeEvent = createInternalHookEvent("gateway", "startup", "gateway:startup");
2027+
await triggerInternalHook(activeEvent);
2028+
expect(activeEvent.messages).toEqual(["legacy-hook-fired"]);
2029+
2030+
loadOpenClawPlugins({
2031+
cache: false,
2032+
workspaceDir: plugin.dir,
2033+
config: {
2034+
plugins:
2035+
nextState === "disabled"
2036+
? {
2037+
load: { paths: [plugin.file] },
2038+
allow: ["internal-hook-lifecycle"],
2039+
entries: {
2040+
"internal-hook-lifecycle": {
2041+
enabled: false,
2042+
},
2043+
},
2044+
}
2045+
: {
2046+
allow: [],
2047+
},
2048+
},
2049+
});
2050+
2051+
const retiredEvent = createInternalHookEvent("gateway", "startup", "gateway:startup");
2052+
await triggerInternalHook(retiredEvent);
2053+
expect(retiredEvent.messages).toStrictEqual([]);
2054+
2055+
clearInternalHooks();
2056+
},
2057+
);
2058+
19832059
it("injects plugin config into internal hook event context", async () => {
19842060
useNoBundledPlugins();
19852061
const plugin = writePlugin({

0 commit comments

Comments
 (0)