Skip to content

Commit e7ca90e

Browse files
fix(hooks): flag hook event names that no core trigger emits (#99456)
* fix(hooks): flag hook event names that no trigger site emits * docs(hooks): clarify bare family subscriptions in unknown-event note * fix(hooks): word unknown-event diagnostics around core-emitted keys * fix(hooks): apply unknown-event advisory to legacy config handlers too
1 parent 3ea875b commit e7ca90e

9 files changed

Lines changed: 189 additions & 1 deletion

File tree

docs/automation/hooks.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ openclaw hooks info session-memory
4545

4646
## Event types
4747

48+
Hooks subscribe to a specific key from this table, or to a bare family name
49+
(`command`, `session`, `agent`, `gateway`, `message`) to receive every action
50+
in that family. OpenClaw core emits nothing else, so any other name is almost
51+
always a typo that leaves the hook silently dead (only a plugin emitting a
52+
custom event could fire it). The hook loader logs a warning for such names
53+
(for example `command:nwe`), and `openclaw hooks info <name>` flags them, so a
54+
hook that never runs is diagnosable.
55+
4856
| Event | When it fires |
4957
| ------------------------ | ---------------------------------------------------------- |
5058
| `command:new` | `/new` command issued |

src/cli/hooks-cli.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const report: HookStatusReport = {
2020
emoji: "💾",
2121
homepage: "https://docs.openclaw.ai/automation/hooks#session-memory",
2222
events: ["command:new"],
23+
unknownEvents: [],
2324
always: false,
2425
enabledByConfig: true,
2526
requirementsSatisfied: true,
@@ -48,6 +49,7 @@ function createPluginManagedHookReport(): HookStatusReport {
4849
emoji: "🔗",
4950
homepage: undefined,
5051
events: ["command:new"],
52+
unknownEvents: [],
5153
always: false,
5254
enabledByConfig: true,
5355
requirementsSatisfied: true,
@@ -79,6 +81,24 @@ describe("hooks cli formatting", () => {
7981
expect(output).toContain("plugin:voice-call");
8082
});
8183

84+
it("warns about unknown events in hook info", () => {
85+
const typoReport: HookStatusReport = {
86+
workspaceDir: "/tmp/workspace",
87+
managedHooksDir: "/tmp/hooks",
88+
hooks: [
89+
{
90+
...report.hooks[0],
91+
name: "typo-hook",
92+
events: ["command:nwe", "command:new"],
93+
unknownEvents: ["command:nwe"],
94+
},
95+
],
96+
};
97+
98+
const output = formatHookInfo(typoReport, "typo-hook", {});
99+
expect(output).toContain("Event not emitted by core (likely typo): command:nwe");
100+
});
101+
82102
it("shows plugin-managed details in hook info", () => {
83103
const pluginReport = createPluginManagedHookReport();
84104

src/cli/hooks-cli.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
191191
source: h.source,
192192
pluginId: h.pluginId,
193193
events: h.events,
194+
unknownEvents: h.unknownEvents,
194195
homepage: h.homepage,
195196
missing: h.missing,
196197
managedByPlugin: h.managedByPlugin,
@@ -300,6 +301,13 @@ export function formatHookInfo(
300301
if (hook.events.length > 0) {
301302
lines.push(`${theme.muted(" Events:")} ${hook.events.join(", ")}`);
302303
}
304+
if (hook.unknownEvents.length > 0) {
305+
lines.push(
306+
theme.warn(
307+
` ⚠ Event${hook.unknownEvents.length === 1 ? "" : "s"} not emitted by core (likely typo): ${hook.unknownEvents.join(", ")}`,
308+
),
309+
);
310+
}
303311
if (hook.managedByPlugin) {
304312
lines.push(theme.muted(" Managed by plugin; enable/disable via hooks CLI not available."));
305313
}

src/commands/onboard-hooks.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ describe("onboard-hooks", () => {
115115
? undefined
116116
: "missing requirements") as HookStatusEntry["blockedReason"],
117117
...params,
118+
unknownEvents: [],
118119
source: "openclaw-bundled" as const,
119120
pluginId: undefined,
120121
homepage: undefined,

src/hooks/hooks-status.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { evaluateEntryRequirementsForCurrentPlatform } from "../shared/entry-sta
55
import type { RequirementConfigCheck, Requirements } from "../shared/requirements.js";
66
import { CONFIG_DIR } from "../utils.js";
77
import { hasBinary, isConfigPathTruthy } from "./config.js";
8+
import { isKnownInternalHookEventKey } from "./internal-hook-types.js";
89
import {
910
resolveHookConfig,
1011
resolveHookEnableState,
@@ -35,6 +36,8 @@ export type HookStatusEntry = {
3536
emoji?: string;
3637
homepage?: string;
3738
events: string[];
39+
/** Declared events no core trigger site emits (likely typos; fire only if a plugin emits them). */
40+
unknownEvents: string[];
3841
always: boolean;
3942
enabledByConfig: boolean;
4043
requirementsSatisfied: boolean;
@@ -96,6 +99,7 @@ function buildHookStatus(
9699
const enableState = resolveHookEnableState({ entry, config, hookConfig });
97100
const always = entry.metadata?.always === true;
98101
const events = entry.metadata?.events ?? [];
102+
const unknownEvents = events.filter((event) => !isKnownInternalHookEventKey(event));
99103
const isEnvSatisfied = (envName: string) =>
100104
Boolean(process.env[envName] || hookConfig?.env?.[envName]);
101105
const isConfigSatisfied = (pathStr: string) => isConfigPathTruthy(config, pathStr);
@@ -127,6 +131,7 @@ function buildHookStatus(
127131
emoji,
128132
homepage,
129133
events,
134+
unknownEvents,
130135
always,
131136
enabledByConfig,
132137
requirementsSatisfied,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Internal hook event key validation tests.
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
isKnownInternalHookEventKey,
5+
KNOWN_INTERNAL_HOOK_EVENT_KEYS,
6+
} from "./internal-hook-types.js";
7+
8+
describe("isKnownInternalHookEventKey", () => {
9+
it("accepts every emitted type:action key", () => {
10+
for (const key of KNOWN_INTERNAL_HOOK_EVENT_KEYS) {
11+
expect(isKnownInternalHookEventKey(key), key).toBe(true);
12+
}
13+
});
14+
15+
it("accepts bare family keys that subscribe to every action", () => {
16+
for (const family of ["command", "session", "agent", "gateway", "message"]) {
17+
expect(isKnownInternalHookEventKey(family), family).toBe(true);
18+
}
19+
});
20+
21+
it("rejects typos, bare actions, and unknown families", () => {
22+
for (const key of [
23+
"command:nwe",
24+
"message:recieved",
25+
"startup", // bare action without its family
26+
"gateway:started",
27+
"session:compact", // partial multi-segment action
28+
"webhook:received",
29+
"",
30+
"COMMAND:NEW",
31+
]) {
32+
expect(isKnownInternalHookEventKey(key), key).toBe(false);
33+
}
34+
});
35+
});

src/hooks/internal-hook-types.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
11
// Internal hook types define runtime hook event families and payload contracts.
22
export type InternalHookEventType = "command" | "session" | "agent" | "gateway" | "message";
33

4+
const KNOWN_INTERNAL_HOOK_EVENT_FAMILIES = [
5+
"command",
6+
"session",
7+
"agent",
8+
"gateway",
9+
"message",
10+
] as const satisfies readonly InternalHookEventType[];
11+
12+
/**
13+
* Event keys emitted by core trigger sites (see docs/automation/hooks.md
14+
* events table — keep both in sync when adding a trigger). Hooks can also
15+
* subscribe to a bare family key to receive every action of that family.
16+
* Plugins can emit additional keys via the deprecated plugin-sdk/hook-runtime
17+
* barrel, so anything outside this set is flagged as a likely typo
18+
* (advisory), not rejected.
19+
*/
20+
export const KNOWN_INTERNAL_HOOK_EVENT_KEYS = [
21+
"agent:bootstrap",
22+
"command:new",
23+
"command:reset",
24+
"command:stop",
25+
"gateway:pre-restart",
26+
"gateway:shutdown",
27+
"gateway:startup",
28+
"message:preprocessed",
29+
"message:received",
30+
"message:sent",
31+
"message:transcribed",
32+
"session:compact:after",
33+
"session:compact:before",
34+
"session:patch",
35+
] as const;
36+
37+
export function isKnownInternalHookEventKey(key: string): boolean {
38+
return (
39+
(KNOWN_INTERNAL_HOOK_EVENT_KEYS as readonly string[]).includes(key) ||
40+
(KNOWN_INTERNAL_HOOK_EVENT_FAMILIES as readonly string[]).includes(key)
41+
);
42+
}
43+
444
export interface InternalHookEvent {
545
/** The type of event (command, session, agent, gateway, etc.) */
646
type: InternalHookEventType;

src/hooks/loader.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,19 @@ describe("loader", () => {
5252
sourceDir?: string;
5353
hookName: string;
5454
handlerCode?: string;
55+
events?: string[];
5556
}): Promise<string> {
5657
const sourceDir = params.sourceDir ?? path.join(tmpDir, "hooks");
5758
const hookDir = path.join(sourceDir, params.hookName);
5859
await fs.mkdir(hookDir, { recursive: true });
60+
const events = params.events ?? ["command:new"];
5961
await fs.writeFile(
6062
path.join(hookDir, "HOOK.md"),
6163
[
6264
"---",
6365
`name: ${params.hookName}`,
6466
`description: ${params.hookName} test hook`,
65-
'metadata: {"openclaw":{"events":["command:new"]}}',
67+
`metadata: {"openclaw":{"events":${JSON.stringify(events)}}}`,
6668
"---",
6769
"",
6870
`# ${params.hookName}`,
@@ -244,6 +246,51 @@ describe("loader", () => {
244246
expect(event.messages).toEqual(["keep-hook"]);
245247
});
246248

249+
it("registers unknown event keys anyway (advisory warning, not a load failure)", async () => {
250+
const hooksDir = path.join(tmpDir, "managed-hooks");
251+
await writeDiscoveredHook({
252+
sourceDir: hooksDir,
253+
hookName: "typo-hook",
254+
events: ["command:nwe", "command:new"],
255+
});
256+
257+
const count = await loadInternalHooks(
258+
{
259+
hooks: {
260+
internal: {
261+
entries: {
262+
"typo-hook": { enabled: true },
263+
},
264+
},
265+
},
266+
} satisfies OpenClawConfig,
267+
tmpDir,
268+
{ managedHooksDir: hooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" },
269+
);
270+
271+
// The typo'd key never fires, but validation is advisory: the hook still
272+
// loads and its valid subscriptions keep working.
273+
expect(count).toBe(1);
274+
const keys = getRegisteredEventKeys();
275+
expect(keys).toContain("command:nwe");
276+
expect(keys).toContain("command:new");
277+
const event = createInternalHookEvent("command", "new", "test-session");
278+
await triggerInternalHook(event);
279+
expect(event.messages).toEqual(["typo-hook"]);
280+
});
281+
282+
it("registers legacy handler events with unknown keys anyway (advisory)", async () => {
283+
const handlerPath = await writeHandlerModule("legacy-typo-handler.js");
284+
285+
const cfg = createEnabledHooksConfig([
286+
{ event: "command:nwe", module: path.basename(handlerPath) },
287+
]);
288+
289+
const count = await loadInternalHooks(cfg, tmpDir);
290+
expect(count).toBe(1);
291+
expect(getRegisteredEventKeys()).toContain("command:nwe");
292+
});
293+
247294
it("should load multiple handlers", async () => {
248295
// Create test handler modules
249296
const handler1Path = await writeHandlerModule("handler1.js");

src/hooks/loader.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { resolveGlobalSingleton } from "../shared/global-singleton.js";
1616
import { shouldIncludeHook } from "./config.js";
1717
import { hasConfiguredInternalHooks, resolveConfiguredInternalHookNames } from "./configured.js";
1818
import { buildImportUrl } from "./import-url.js";
19+
import { isKnownInternalHookEventKey } from "./internal-hook-types.js";
1920
import type { InternalHookHandler } from "./internal-hooks.js";
2021
import { registerInternalHook, unregisterInternalHook } from "./internal-hooks.js";
2122
import { getLegacyInternalHookHandlers } from "./legacy-config.js";
@@ -168,6 +169,20 @@ export async function loadInternalHooks(
168169
continue;
169170
}
170171

172+
// Core never emits keys outside the known set, so these are almost
173+
// always typos that leave the hook silently dead (a plugin could emit
174+
// custom keys via plugin-sdk/hook-runtime, hence advisory: warn but
175+
// still register).
176+
const unknownEvents = events.filter((event) => !isKnownInternalHookEventKey(event));
177+
if (unknownEvents.length > 0) {
178+
log.warn(
179+
`Hook '${safeLogValue(entry.hook.name)}' subscribes to event${unknownEvents.length === 1 ? "" : "s"} ` +
180+
`${unknownEvents.map((event) => safeLogValue(event)).join(", ")} not emitted by OpenClaw core — ` +
181+
`likely a typo; unless a plugin emits it, the hook never fires. ` +
182+
`Known events: https://docs.openclaw.ai/automation/hooks`,
183+
);
184+
}
185+
171186
for (const event of events) {
172187
registerInternalHook(event, handler);
173188
loadedHookRegistrations.push({ event, handler });
@@ -259,6 +274,15 @@ export async function loadInternalHooks(
259274
continue;
260275
}
261276

277+
// Same advisory typo check as directory-discovered hooks above.
278+
if (!isKnownInternalHookEventKey(handlerConfig.event)) {
279+
log.warn(
280+
`Legacy hook handler ${safeLogValue(rawModule)} subscribes to event ` +
281+
`${safeLogValue(handlerConfig.event)} not emitted by OpenClaw core — ` +
282+
`likely a typo; unless a plugin emits it, the hook never fires. ` +
283+
`Known events: https://docs.openclaw.ai/automation/hooks`,
284+
);
285+
}
262286
registerInternalHook(handlerConfig.event, handler);
263287
loadedHookRegistrations.push({ event: handlerConfig.event, handler });
264288
log.debug(

0 commit comments

Comments
 (0)