Skip to content

hooks: unify internal + plugin hook registries to fix 5 systemic bugs#30853

Closed
widingmarcus-cyber wants to merge 5 commits intoopenclaw:mainfrom
widingmarcus-cyber:fix/unify-hook-registries
Closed

hooks: unify internal + plugin hook registries to fix 5 systemic bugs#30853
widingmarcus-cyber wants to merge 5 commits intoopenclaw:mainfrom
widingmarcus-cyber:fix/unify-hook-registries

Conversation

@widingmarcus-cyber
Copy link
Copy Markdown
Contributor

Summary

Fixes #30784 — The codebase has two independent hook systems with overlapping responsibilities and duplicated module-level singleton state. This PR fixes all 5 systemic bugs from the same root cause.

What changed

  • Bundler-safe singletons: internal-hooks.ts and hook-runner-global.ts now use globalThis[Symbol.for()] instead of module-level variables — same pattern as plugins/runtime.ts. Hooks survive Rolldown chunk splitting.
  • Source-scoped clearing: Every registered hook now carries source metadata ("bundled", "workspace", "managed", "config", "plugin"). clearInternalHooksBySource() preserves plugin hooks during hot-reload.
  • Unified dispatch helpers: New src/hooks/dispatch-unified.tsemitMessageReceived(), emitMessageSent(), emitGatewayStartup() — replaces 15-30 lines of manual dual-dispatch per call site.
  • gateway:startup race fix: Removed setTimeout(250), both hooks now fire together via emitGatewayStartup() after sidecars are fully loaded.
  • sessions.reset before_reset: Gateway RPC handler now fires the plugin before_reset hook (matching commands-core.ts behavior).
  • after_compaction sessionKey: Context now includes sessionKey (matching before_compaction).

Files

File Change
src/hooks/dispatch-unified.ts New — unified dispatch helpers
src/hooks/internal-hooks.ts globalThis singleton, source metadata, source-scoped clearing
src/plugins/hook-runner-global.ts globalThis singleton
src/hooks/loader.ts Pass hook source to registerInternalHook
src/plugins/registry.ts Pass "plugin" source to registerInternalHook
src/gateway/server-startup.ts Source-scoped clear, remove setTimeout race
src/gateway/server.impl.ts Use emitGatewayStartup dispatch helper
src/auto-reply/reply/dispatch-from-config.ts Use emitMessageReceived dispatch helper
src/infra/outbound/deliver.ts Use emitMessageSent dispatch helper
src/gateway/server-methods/sessions.ts Add missing before_reset plugin hook
src/agents/pi-embedded-subscribe.handlers.compaction.ts Add missing sessionKey to after_compaction

1 new file, 10 modified. ~160 LOC new. Backward compatible — no changes to HookRunner facade, existing hook handler signatures, or the 24 typed hook definitions.

Test plan

  • Verify registerInternalHook with 2 args still works (backward compat)
  • Verify plugin hooks survive clearInternalHooksBySource(["bundled", "workspace", "managed", "config"])
  • Verify gateway startup fires both internal and plugin hooks without race condition
  • Verify /reset from gateway RPC fires before_reset plugin hook
  • Verify after_compaction hook receives sessionKey in context
  • Run existing hook test suite (src/hooks/internal-hooks.test.ts)
  • Run existing plugin hooks test (src/plugins/wired-hooks-compaction.test.ts)

Fixes openclaw#30784

**Bug 1 — Bundler duplication**: Replace module-level singletons in
internal-hooks.ts and hook-runner-global.ts with
globalThis[Symbol.for()] pattern (already proven in plugins/runtime.ts).
Hooks now survive Rolldown chunk splitting.

**Bug 2 — clearInternalHooks() wipes plugin hooks**: Add source metadata
to every registered hook ("bundled", "workspace", "managed", "config",
"plugin"). Replace handlers.clear() with clearInternalHooksBySource()
that preserves plugin-registered hooks during hot-reload.

**Bug 3 — gateway:startup race condition**: Remove setTimeout(250) from
server-startup.ts. Both gateway_start (plugin) and gateway:startup
(internal) now fire together via emitGatewayStartup() after sidecars are
fully loaded.

**Bug 4 — sessions.reset missing before_reset**: Add the plugin
before_reset hook call to the gateway RPC handler in sessions.ts,
matching the existing behavior in commands-core.ts.

**Bug 5 — after_compaction missing sessionKey**: Pass
ctx.params.sessionKey to the after_compaction hook context, matching the
before_compaction hook.

**Dispatch helpers**: New src/hooks/dispatch-unified.ts co-locates dual
dispatch for the 3 overlapping events (message_received, message_sent,
gateway_start). Each call site now uses one function instead of 15-30
lines of manual dual dispatch.

Files: 1 new, 10 modified. ~160 LOC new. Backward compatible — no
changes to HookRunner facade or existing hook handler signatures.
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 1, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Event-loop blocking / DoS via synchronous unbounded transcript read in sessions.reset before_reset hook
2 🟡 Medium Persistent plugin internal hooks survive in-process restarts due to globalThis hook registry (Symbol.for), bypassing config/plugin disable and causing handler accumulation

1. 🟡 Event-loop blocking / DoS via synchronous unbounded transcript read in sessions.reset before_reset hook

Property Value
Severity Medium
CWE CWE-400
Location src/gateway/server-methods/sessions.ts:372-389

Description

In sessionsHandlers.reset, the new before_reset plugin hook implementation reads the entire session transcript synchronously and then parses it line-by-line.

  • Input influence: sessionFile is taken from the persisted session store (entry.sessionFile). While it is not directly user-supplied via the API, it is still data-driven (persisted metadata) and can point to a very large transcript file in normal operation.
  • DoS impact: fs.readFileSync(sessionFile, "utf-8") blocks the Node.js event loop for the duration of the read and allocates memory for the full file contents. A large transcript (or repeated resets) can significantly delay or stall the gateway process.
  • Amplification: after building messages, the handler then awaits hookRunner.runBeforeReset(...). Plugin handlers are executed in parallel but can still be slow; this directly extends the latency of the reset endpoint.

Vulnerable code:

const fileContent = fs.readFileSync(sessionFile, "utf-8");
for (const line of fileContent.split("\n")) {
  ...
  const parsed = JSON.parse(line);
  ...
}
await hookRunner.runBeforeReset({ sessionFile, messages, reason: commandReason }, ...)

Even though sessions.reset is an admin-scoped RPC, this is still a concrete availability risk: any authenticated admin client (or an automated integration) can trigger it against sessions with large transcripts, blocking the entire gateway event loop during synchronous I/O and JSON parsing.

Recommendation

Avoid synchronous, unbounded reads in request handlers.

  1. Limit size before reading (fail closed / degrade gracefully):
    • statSync(sessionFile).size and cap to a reasonable maximum (e.g. 1–5MB) for this hook.
  2. Use streaming async reads and stop after N bytes/lines/messages.
  3. Add an execution deadline around plugin hooks so reset cannot be stalled indefinitely.
  4. Optionally harden path handling by re-resolving sessionFile via resolveSessionFilePath(...) or using a safe open helper with O_NOFOLLOW to avoid unexpected symlink/path behavior if persisted metadata is corrupt.

Example (stream + caps):

import { createReadStream } from "node:fs";
import readline from "node:readline";

const MAX_BYTES = 5 * 1024 * 1024;
const MAX_MESSAGES = 2000;

const stat = await fs.promises.stat(sessionFile);
if (stat.size > MAX_BYTES) {// either skip, or only read tail/head depending on needs
}

const rl = readline.createInterface({
  input: createReadStream(sessionFile, { encoding: "utf-8" }),
  crlfDelay: Infinity,
});

const messages: unknown[] = [];
for await (const line of rl) {
  if (messages.length >= MAX_MESSAGES) break;
  if (!line.trim()) continue;
  try {
    const parsed = JSON.parse(line);
    if (parsed?.type === "message" && parsed?.message) messages.push(parsed.message);
  } catch {}
}

await Promise.race([
  hookRunner.runBeforeReset({ sessionFile, messages, reason }, ctx),
  new Promise((_, reject) => setTimeout(() => reject(new Error("before_reset timeout")), 2000)),
]);

2. 🟡 Persistent plugin internal hooks survive in-process restarts due to globalThis hook registry (Symbol.for), bypassing config/plugin disable and causing handler accumulation

Property Value
Severity Medium
CWE CWE-284
Location src/gateway/server-startup.ts:107-112

Description

The internal hook registry was moved to a globalThis singleton keyed by Symbol.for("openclaw:hookRegistry"). During gateway startup, hooks are cleared by source excluding the plugin source:

  • Plugin hook handlers registered in a previous lifecycle remain in memory across SIGUSR1 in-process restarts (the gateway run-loop restarts the server in the same Node process).
  • If plugins are disabled/removed or hooks.internal.enabled is turned off and the gateway restarts in-process, previously-registered plugin internal hooks can still fire because they were not cleared.
  • If plugin configuration changes and plugins re-register, old plugin handlers are retained, leading to duplicate executions and unbounded growth of handler arrays.

This creates a security-relevant integrity issue (configuration meant to disable hooks/plugins may not actually stop their code from executing) and a potential DoS vector via handler accumulation.

Vulnerable code (startup clears everything except plugin hooks):

// Clear file-discovered hooks but preserve plugin-registered hooks (#30784)
clearInternalHooksBySource(["bundled", "workspace", "managed", "config"]);
const loadedCount = await loadInternalHooks(params.cfg, params.defaultWorkspaceDir);

Because the registry itself persists on globalThis, the preserved plugin handlers can continue to run on future triggerInternalHook(...) calls even when no longer desired.

Recommendation

Ensure plugin internal hooks cannot outlive the lifecycle/configuration that enabled them.

Recommended options:

  1. Clear plugin hooks on every gateway (re)startup iteration, then re-register from the currently loaded plugin registry:
// On startup/restart, clear all sources including plugin
clearInternalHooksBySource(["bundled","workspace","managed","config","plugin"]);
await loadInternalHooks(cfg, workspaceDir);// plugins load should then register their hooks for this lifecycle
  1. Track plugin registrations with identifiers and support targeted cleanup:
  • Extend HookRegistryEntry with pluginId (or a lifecycleToken).
  • On plugin reload/disable, call clearInternalHooksBySource(["plugin"]) or clearInternalHooksByPluginId(pluginId).
  1. If the intent is only to dedupe module instances (not persist across restarts), store the registry in module scope and explicitly clear it during SIGUSR1 restart handling.

Add regression tests:

  • start with plugin hooks enabled; restart with hooks disabled; assert plugin internal hook handler does not execute.
  • change plugin set across restarts; assert no duplicate executions and no stale handlers.

Analyzed PR: #30853 at commit 9751562

Last updated on: 2026-03-01T19:42:24Z

@openclaw-barnacle openclaw-barnacle bot added gateway Gateway runtime agents Agent runtime and tooling size: M experienced-contributor labels Mar 1, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d2e374c453

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

from: params.from,
content: params.content,
timestamp: params.timestamp,
metadata: params.metadata ?? {},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve messageId in plugin message_received payloads

emitMessageReceived stops forwarding params.messageId to the plugin hook payload and only passes params.metadata as-is, while the old call path included metadata.messageId. This breaks existing message_received plugins that rely on message IDs for correlation/deduping (internal hooks still get messageId, so behavior is now inconsistent between the two hook systems).

Useful? React with 👍 / 👎.

await hookRunner.runBeforeReset(
{ sessionFile, messages, reason: commandReason },
{
agentId: (target.canonicalKey ?? key).split(":")[0] ?? "main",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive before_reset agentId from parsed session keys

The new before_reset context computes agentId with (target.canonicalKey ?? key).split(":")[0], which returns the literal prefix "agent" for canonical keys like agent:ops:main instead of the actual agent (ops). Any plugin using ctx.agentId during gateway sessions.reset will attach state/memory to the wrong agent.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 1, 2026

Greptile Summary

unified internal and plugin hook registries to resolve 5 systemic bugs related to hook lifecycle management

Key improvements:

  • Bundler-safe singletons: both internal-hooks.ts and hook-runner-global.ts now use globalThis[Symbol.for()] pattern, ensuring hooks survive module bundling and chunk splitting
  • Source-scoped hook clearing: hooks are tagged with source metadata (bundled, workspace, managed, config, plugin), enabling selective clearing during hot-reload while preserving plugin hooks
  • Unified dispatch helpers: new dispatch-unified.ts consolidates dual-dispatch logic for message_received, message_sent, and gateway_startup events
  • Race condition fixes: removed setTimeout(250) delay for gateway:startup, both internal and plugin hooks now fire synchronously after sidecars load; sessions.reset now reads session file before calling before_reset hook and awaits completion before archival
  • API completeness: after_compaction hook now receives sessionKey in context, matching before_compaction

Technical implementation:

  • Backward compatible: registerInternalHook accepts optional source parameter (defaults to config)
  • Proper error handling: plugin hooks log failures via logVerbose, internal hooks log via triggerInternalHook
  • Correct source mapping: filesystem-discovered plugin hooks map to workspace (cleared on reload), programmatic plugin hooks use plugin source (preserved on reload)

Confidence Score: 4/5

  • safe to merge after the race condition and mapping issues were addressed in follow-up commits
  • the initial PR introduced several issues (race conditions, incorrect source mapping, missing error logging) that were caught and fixed in subsequent commits 2e7972d, d7d638f, and 9751562. the current code correctly implements bundler-safe singletons, source-scoped clearing, and proper sequencing to prevent race conditions. the changes are backward compatible and well-tested based on the test plan
  • all identified issues in dispatch-unified.ts, loader.ts, and sessions.ts have been resolved in follow-up commits

Last reviewed commit: 9751562

…eset

- Forward messageId into plugin metadata in emitMessageReceived to
  maintain parity with the old dual-dispatch path (openclaw#30784)
- Fix agentId derivation for canonical session keys (agent:X:Y) in
  before_reset hook context — previously returned literal 'agent'
  instead of the actual agent id
@widingmarcus-cyber
Copy link
Copy Markdown
Contributor Author

@greptile-apps please review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0fb80b0c6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// Clear any previously registered hooks to ensure fresh loading
clearInternalHooks();
// Clear file-discovered hooks but preserve plugin-registered hooks (#30784)
clearInternalHooksBySource(["bundled", "workspace", "managed", "config"]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear plugin-source hooks on gateway sidecar startup

When the gateway uses an in-process restart path (for example OPENCLAW_NO_RESPAWN=1 or respawn fallback), this call preserves all prior source: "plugin" internal hooks from the previous lifecycle. Plugin loading can then register plugin hooks again (registerInternalHook(event, handler, "plugin") in src/plugins/registry.ts), which leaves disabled plugins' handlers active and can double-run unchanged hooks after restart. Clearing plugin-source entries before reloading avoids stale/duplicate hook execution in these restart contexts.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

11 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +367 to +406
// Fire before_reset plugin hook — extract memories before session history is lost (#30784)
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_reset")) {
const sessionFile = entry?.sessionFile;
void (async () => {
try {
const messages: unknown[] = [];
if (sessionFile) {
const content = fs.readFileSync(sessionFile, "utf-8");
for (const line of content.split("\n")) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line);
if (parsed.type === "message" && parsed.message) {
messages.push(parsed.message);
}
} catch {
// skip malformed lines
}
}
}
await hookRunner.runBeforeReset(
{ sessionFile, messages, reason: commandReason },
{
agentId: (() => {
const k = target.canonicalKey ?? key;
const parts = k.split(":");
return parts[0] === "agent" && parts[1] ? parts[1] : (parts[0] ?? "main");
})(),
sessionKey: target.canonicalKey ?? key,
sessionId: entry?.sessionId,
},
);
} catch {
// Don't block session reset on hook failure
}
})();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

race condition: file read vs archival. the plugin hook fires in a fire-and-forget async function that reads sessionFile with fs.readFileSync (line 375), but the main thread immediately proceeds to call archiveSessionTranscriptsForSession (line 450) which renames the file with fs.renameSync. since the async function isn't awaited, the file might be renamed before the read completes, causing the hook to fail silently.

fix: move the file read operation outside the async IIFE (before the hook is called) and pass the already-read messages to the hook. this ensures the file is read before archival begins.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/sessions.ts
Line: 367-406

Comment:
race condition: file read vs archival. the plugin hook fires in a fire-and-forget async function that reads `sessionFile` with `fs.readFileSync` (line 375), but the main thread immediately proceeds to call `archiveSessionTranscriptsForSession` (line 450) which renames the file with `fs.renameSync`. since the async function isn't awaited, the file might be renamed before the read completes, causing the hook to fail silently.

fix: move the file read operation outside the async IIFE (before the hook is called) and pass the already-read messages to the hook. this ensures the file is read before archival begins.

How can I resolve this? If you propose a fix, please make it concise.

Read session file synchronously before cleanup/archive can rename it.
Previously the file read was inside a fire-and-forget async block that
raced against ensureSessionRuntimeCleanup which renames the session
file. Now the file content is captured before any cleanup runs, and
only the hook execution itself is async.

Addresses Greptile 2/5 score feedback.
@widingmarcus-cyber
Copy link
Copy Markdown
Contributor Author

@greptile-apps please review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d7d638f07d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +403 to +406
agentId: resetAgentId,
sessionKey: target.canonicalKey ?? key,
sessionId: entry?.sessionId,
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include workspaceDir in gateway before_reset hook context

sessions.reset now emits before_reset, but the context passed to runBeforeReset omits workspaceDir, unlike the existing /new//reset command path in emitResetCommandHooks (src/auto-reply/reply/commands-core.ts) which includes it. Plugins that resolve memory/db files from ctx.workspaceDir will work for chat-command resets but fail (or silently skip work) when the same reset is triggered through the gateway RPC, creating inconsistent behavior across reset entrypoints.

Useful? React with 👍 / 👎.

Comment on lines +24 to +31
switch (source) {
case "openclaw-bundled": return "bundled";
case "openclaw-managed": return "managed";
case "openclaw-workspace": return "workspace";
case "openclaw-plugin": return "plugin";
default: return "config";
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate hook registrations on hot-reload for "openclaw-plugin" workspace hooks

resolveHookSource maps "openclaw-plugin" entries to "plugin", but clearInternalHooksBySource(["bundled", "workspace", "managed", "config"]) does not include "plugin" in the cleared set. This means workspace-discovered hooks from plugin directories are never cleared before the next call to loadInternalHooks.

The sequence on hot-reload is:

  1. clearInternalHooksBySource(["bundled", "workspace", "managed", "config"]) — skips "plugin" hooks, so "openclaw-plugin" workspace hooks remain registered.
  2. loadInternalHooks calls loadWorkspaceHookEntries again, discovers the same "openclaw-plugin" entries, and re-registers them.
  3. The handler is now registered twice, so each matching event fires the handler twice.

The "plugin" source was intended to protect hooks registered programmatically via src/plugins/registry.ts, not filesystem-discovered plugin hooks that loadInternalHooks re-scans on every reload. Mapping "openclaw-plugin" workspace entries to the same "plugin" source accidentally opts them into the "never clear" bucket while loadInternalHooks still re-registers them.

Consider returning a distinct source (e.g. "workspace") for "openclaw-plugin" entries so they are cleared and re-registered cleanly, or filtering them out of the loadWorkspaceHookEntries scan when they are already registered:

Suggested change
switch (source) {
case "openclaw-bundled": return "bundled";
case "openclaw-managed": return "managed";
case "openclaw-workspace": return "workspace";
case "openclaw-plugin": return "plugin";
default: return "config";
}
}
function resolveHookSource(source: string): HookSource {
switch (source) {
case "openclaw-bundled": return "bundled";
case "openclaw-managed": return "managed";
case "openclaw-workspace": return "workspace";
case "openclaw-plugin": return "workspace";
default: return "config";
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/loader.ts
Line: 24-31

Comment:
**Duplicate hook registrations on hot-reload for `"openclaw-plugin"` workspace hooks**

`resolveHookSource` maps `"openclaw-plugin"` entries to `"plugin"`, but `clearInternalHooksBySource(["bundled", "workspace", "managed", "config"])` does not include `"plugin"` in the cleared set. This means workspace-discovered hooks from plugin directories are never cleared before the next call to `loadInternalHooks`.

The sequence on hot-reload is:
1. `clearInternalHooksBySource(["bundled", "workspace", "managed", "config"])` — skips `"plugin"` hooks, so `"openclaw-plugin"` workspace hooks remain registered.
2. `loadInternalHooks` calls `loadWorkspaceHookEntries` again, discovers the same `"openclaw-plugin"` entries, and re-registers them.
3. The handler is now registered **twice**, so each matching event fires the handler twice.

The `"plugin"` source was intended to protect hooks registered programmatically via `src/plugins/registry.ts`, not filesystem-discovered plugin hooks that `loadInternalHooks` re-scans on every reload. Mapping `"openclaw-plugin"` workspace entries to the same `"plugin"` source accidentally opts them into the "never clear" bucket while `loadInternalHooks` still re-registers them.

Consider returning a distinct source (e.g. `"workspace"`) for `"openclaw-plugin"` entries so they are cleared and re-registered cleanly, or filtering them out of the `loadWorkspaceHookEntries` scan when they are already registered:

```suggestion
function resolveHookSource(source: string): HookSource {
  switch (source) {
    case "openclaw-bundled": return "bundled";
    case "openclaw-managed": return "managed";
    case "openclaw-workspace": return "workspace";
    case "openclaw-plugin": return "workspace";
    default: return "config";
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +44 to +46
)
.catch(() => {});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent plugin hook error swallowing removes observability

The previous call sites logged plugin hook failures via logVerbose:

// old dispatch-from-config.ts
.catch((err) => {
  logVerbose(`dispatch-from-config: message_received plugin hook failed: ${String(err)}`);
});

The new unified helpers use empty .catch(() => {}) throughout (emitMessageReceived, emitMessageSent, and emitGatewayStartup), so plugin hook errors are completely silent. Note that triggerInternalHook already logs errors internally, so internal hook failures are still visible — but plugin hook failures via hookRunner.run*() are not.

Consider adding at least a logVerbose in the catch to preserve the previous debug signal:

Suggested change
)
.catch(() => {});
}
.catch((err) => {
logVerbose(`dispatch-unified: message_received plugin hook failed: ${String(err)}`);
});

(And similarly for the emitMessageSent and emitGatewayStartup plugin hook calls at lines 94 and 129.)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/dispatch-unified.ts
Line: 44-46

Comment:
**Silent plugin hook error swallowing removes observability**

The previous call sites logged plugin hook failures via `logVerbose`:

```ts
// old dispatch-from-config.ts
.catch((err) => {
  logVerbose(`dispatch-from-config: message_received plugin hook failed: ${String(err)}`);
});
```

The new unified helpers use empty `.catch(() => {})` throughout (`emitMessageReceived`, `emitMessageSent`, and `emitGatewayStartup`), so plugin hook errors are completely silent. Note that `triggerInternalHook` already logs errors internally, so internal hook failures are still visible — but plugin hook failures via `hookRunner.run*()` are not.

Consider adding at least a `logVerbose` in the catch to preserve the previous debug signal:

```suggestion
      .catch((err) => {
        logVerbose(`dispatch-unified: message_received plugin hook failed: ${String(err)}`);
      });
```

(And similarly for the `emitMessageSent` and `emitGatewayStartup` plugin hook calls at lines 94 and 129.)

How can I resolve this? If you propose a fix, please make it concise.

- Change "openclaw-plugin" → "workspace" in resolveHookSource so
  plugin-discovered hooks are properly cleared on hot-reload
- Replace silent .catch(() => {}) with logVerbose in dispatch-unified
  to preserve debug observability for plugin hook failures
The before_reset plugin hook was fire-and-forget (void), so
archiveSessionTranscriptsForSession could rename the session file
before the hook finished executing. Await the hook to guarantee
it completes before the file is archived. Also add logVerbose
to the catch handler for debug observability.

Fixes the race condition identified in openclaw#30784.
@widingmarcus-cyber
Copy link
Copy Markdown
Contributor Author

@greptile review

Addressed all three findings from the previous review:

  1. resolveHookSource mapping"openclaw-plugin" now maps to "workspace" (was "plugin"), fixing duplicate hooks on hot-reload
  2. Silent error swallowing — Added logVerbose to all plugin hook .catch() handlers in dispatch-unified.ts
  3. Race condition in sessions.tsbefore_reset hook is now awaited before archiveSessionTranscriptsForSession renames the file

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9751562e53

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +401 to +403
await hookRunner
.runBeforeReset(
{ sessionFile, messages, reason: commandReason },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run before_reset only after reset preconditions succeed

sessions.reset now executes before_reset before ensureSessionRuntimeCleanup, but that cleanup can still fail (for example when waitForEmbeddedPiRunEnd times out), in which case the RPC returns UNAVAILABLE and no reset happens. In that failure path the plugin hook has already run and may persist memories or trigger external side effects for a reset that never occurred, which can cause duplicated or inconsistent plugin state on retry.

Useful? React with 👍 / 👎.

@mcaxtr
Copy link
Copy Markdown
Contributor

mcaxtr commented Mar 3, 2026

Hey, nice work tackling the root cause here — the 5 bugs all tracing back to the singleton duplication + clearing issue is a good catch. A few things I think would strengthen this before merge:

Tests

This PR introduces clearInternalHooksBySource, resolveHookSource, three new dispatch helpers, and the globalThis singleton pattern — but adds no tests for any of them. The source-scoped clearing logic is the core behavioral change and should have at least:

  • A test proving plugin-source hooks survive clearInternalHooksBySource(["bundled", "workspace", "managed", "config"])
  • A test proving the Symbol.for singleton survives simulated module duplication (the whole point of the refactor)
  • Tests for the dispatch helpers (null hookRunner, missing sessionKey, error propagation)
  • A test for resolveHookSource mapping correctness

Registry separation

Right now the registry state, source-scoped clearing, and hook lookup are all embedded inside internal-hooks.ts. Extracting the registry into its own module with a clean API (registerHook, unregisterHook, clearHooksBySource, getHookEntries, hasHooks, etc.) would make it independently testable and keep internal-hooks.ts focused on hook-specific dispatch logic. It also makes it reusable if a third hook source appears down the line.

Inline source array

The ["bundled", "workspace", "managed", "config"] list is used as a raw array literal at the call site. A named constant (e.g., FILE_HOOK_SOURCES) would be self-documenting and prevent drift if another file-based source is added later.

Plugin loader cache key

buildCacheKey() in src/plugins/loader.ts doesn't include hooks.internal.enabled. If someone toggles that config flag and does a SIGUSR1 restart, the cache hits and stale plugin-source internal hooks persist. Worth adding hooksEnabled to the cache key.

fs.readFileSync in the RPC handler

The before_reset path in sessions.ts uses fs.readFileSync to read session transcripts. These can be large and this blocks the event loop in the gateway RPC handler. Consider using an async read — the same JSONL parsing pattern already exists in commands-core.ts, so this is a good opportunity to extract a shared async helper (e.g., readSessionMessagesAsync in session-utils.fs.ts) and deduplicate both call sites.

before_reset ordering concern

The hook fires before ensureSessionRuntimeCleanup. If cleanup fails (e.g., waitForEmbeddedPiRunEnd times out), the RPC returns UNAVAILABLE and no reset actually happens — but the plugin hook already ran. Depending on what plugins do in before_reset (e.g., persisting memories), this could cause side-effects for a reset that never occurred.

Missing workspaceDir in before_reset context

The gateway RPC path omits workspaceDir from the before_reset hook context, but the matching commands-core.ts path includes it. This creates inconsistent behavior for plugin hooks across the two reset entry points.

Metadata mutation isolation

In the dispatch helpers, the plugin hook and internal hook paths should receive separate object allocations for metadata/context. Currently emitMessageReceived passes params.metadata ?? {} directly to the internal hook path without cloning — if a plugin hook mutates the metadata object, the internal hook sees the mutation (and vice versa).

Error logging consistency

The internal hook .catch() handlers went through a few iterations across commits (silent () => {}logVerbose). Worth a pass to make sure all fire-and-forget paths consistently use logVerbose for observability. Silent swallowing makes hook failures invisible in debug logs.

AGENTS.md documentation

The hook system now has meaningful architectural invariants (globalThis storage, source-scoped clearing semantics, which sources get cleared on hot-reload, why plugin clearing lives in loadOpenClawPlugins on the non-cached path). Adding a "Hook System Architecture" section to AGENTS.md would help future contributors avoid reintroducing the same bugs.

You can use #30860 as reference.

@widingmarcus-cyber
Copy link
Copy Markdown
Contributor Author

Closing — significant merge conflicts after 12 days. The hooks system has evolved substantially. Will re-evaluate if still needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling gateway Gateway runtime size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hooks: unify internal + plugin hook registries to fix 5 systemic bugs

3 participants