From aa7e86bd699eedad9acbe200ec886317268fb1c4 Mon Sep 17 00:00:00 2001 From: Rohit Amarnath <88762+ramarnat@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:49:34 -0500 Subject: [PATCH 1/5] fix(hooks): clear internal hooks before plugins register Plugins may register internal hooks during plugin registration. The gateway previously cleared internal hooks while loading file-based hooks, wiping plugin-registered hooks like session:start. Clear once before plugin loading and keep hook discovery additive. Adds a regression test covering plugin-registered internal hooks surviving gateway sidecar hook loading. --- ...rver-startup.plugin-internal-hooks.test.ts | 112 ++++++++++++++++++ src/gateway/server-startup.ts | 10 +- src/gateway/server.impl.ts | 5 + 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 src/gateway/server-startup.plugin-internal-hooks.test.ts diff --git a/src/gateway/server-startup.plugin-internal-hooks.test.ts b/src/gateway/server-startup.plugin-internal-hooks.test.ts new file mode 100644 index 00000000000..bda0df831e8 --- /dev/null +++ b/src/gateway/server-startup.plugin-internal-hooks.test.ts @@ -0,0 +1,112 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createDefaultDeps } from "../cli/deps.js"; +import { + clearInternalHooks, + createInternalHookEvent, + triggerInternalHook, +} from "../hooks/internal-hooks.js"; +import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { startGatewaySidecars } from "./server-startup.js"; + +function createSilentLogger() { + return { + info: (_msg: string) => {}, + warn: (_msg: string) => {}, + error: (_msg: string) => {}, + debug: (_msg: string) => {}, + }; +} + +async function writeTestPlugin(params: { + dir: string; + id: string; + message: string; +}): Promise { + await fs.mkdir(params.dir, { recursive: true }); + await fs.writeFile( + path.join(params.dir, "openclaw.plugin.json"), + JSON.stringify({ id: params.id, configSchema: {} }, null, 2), + "utf-8", + ); + await fs.writeFile( + path.join(params.dir, "index.ts"), + [ + "export default function register(api) {", + ` api.registerHook("session:start", async (event) => { event.messages.push(${JSON.stringify(params.message)}); }, { name: ${JSON.stringify(`${params.id}:session-start`)} });`, + "}", + "", + ].join("\n"), + "utf-8", + ); +} + +describe("gateway startup internal hooks", () => { + afterEach(() => { + clearInternalHooks(); + }); + + test("does not clear plugin internal hooks when loading workspace hooks", async () => { + vi.stubEnv("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", "1"); + vi.stubEnv("OPENCLAW_SKIP_GMAIL_WATCHER", "1"); + vi.stubEnv("OPENCLAW_SKIP_CHANNELS", "1"); + + vi.useFakeTimers(); + try { + clearInternalHooks(); + + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hook-test-")); + try { + const workspaceDir = path.join(tmpRoot, "workspace"); + const pluginDir = path.join(tmpRoot, "plugin"); + const pluginId = "test-plugin-internal-hooks"; + const marker = "plugin-hook-ran"; + + await fs.mkdir(workspaceDir, { recursive: true }); + await writeTestPlugin({ dir: pluginDir, id: pluginId, message: marker }); + + const cfg: OpenClawConfig = { + hooks: { internal: { enabled: true } }, + plugins: { + enabled: true, + allow: [pluginId], + load: { paths: [pluginDir] }, + }, + }; + + // Plugin registration may register internal hooks immediately. + const pluginRegistry = loadOpenClawPlugins({ + config: cfg, + workspaceDir, + cache: false, + logger: createSilentLogger(), + }); + + await startGatewaySidecars({ + cfg, + pluginRegistry, + defaultWorkspaceDir: workspaceDir, + deps: createDefaultDeps(), + startChannels: async () => {}, + log: { warn: () => {} }, + logHooks: createSilentLogger(), + logChannels: { info: () => {}, error: () => {} }, + logBrowser: { error: () => {} }, + }); + + await vi.runAllTimersAsync(); + + const event = createInternalHookEvent("session", "start", "test-session", {}); + await triggerInternalHook(event); + expect(event.messages).toContain(marker); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + } + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts index 01ec6266df6..e65b6883227 100644 --- a/src/gateway/server-startup.ts +++ b/src/gateway/server-startup.ts @@ -13,11 +13,7 @@ import type { CliDeps } from "../cli/deps.js"; import type { loadConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { startGmailWatcherWithLogs } from "../hooks/gmail-watcher-lifecycle.js"; -import { - clearInternalHooks, - createInternalHookEvent, - triggerInternalHook, -} from "../hooks/internal-hooks.js"; +import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js"; import { loadInternalHooks } from "../hooks/loader.js"; import { isTruthyEnvValue } from "../infra/env.js"; import type { loadOpenClawPlugins } from "../plugins/loader.js"; @@ -110,8 +106,8 @@ export async function startGatewaySidecars(params: { // Load internal hook handlers from configuration and directory discovery. try { - // Clear any previously registered hooks to ensure fresh loading - clearInternalHooks(); + // Internal hooks are cleared once at gateway startup before plugins load. + // Do not clear here: plugins may register internal hooks during plugin registration. const loadedCount = await loadInternalHooks(params.cfg, params.defaultWorkspaceDir); if (loadedCount > 0) { params.logHooks.info( diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index b3e6a9b3c15..ababd582270 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -20,6 +20,7 @@ import { } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { resolveMainSessionKey } from "../config/sessions.js"; +import { clearInternalHooks } from "../hooks/internal-hooks.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { ensureControlUiAssetsBuilt, @@ -381,6 +382,10 @@ export async function startGatewayServer( const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); const baseMethods = listGatewayMethods(); + // Reset internal hook registry before plugins register internal hooks. + // Plugins may call `api.registerHook(...)` during registration, so clearing later + // (e.g. during hook discovery) would wipe plugin-registered hooks like session:start. + clearInternalHooks(); const emptyPluginRegistry = createEmptyPluginRegistry(); const { pluginRegistry, gatewayMethods: baseGatewayMethods } = minimalTestGateway ? { pluginRegistry: emptyPluginRegistry, gatewayMethods: baseMethods } From d67964d2d5f08a60eb55359a90a0870cc4fda4de Mon Sep 17 00:00:00 2001 From: Rohit Amarnath <88762+ramarnat@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:09:45 -0500 Subject: [PATCH 2/5] chore: refresh PR head after rebase conflict resolution From a9b378a0b10816f5bf1d37fdddc9f3f744c1d9bd Mon Sep 17 00:00:00 2001 From: Rohit Amarnath <88762+ramarnat@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:16:15 -0500 Subject: [PATCH 3/5] style(test): format internal hooks startup regression test --- src/gateway/server-startup.plugin-internal-hooks.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/server-startup.plugin-internal-hooks.test.ts b/src/gateway/server-startup.plugin-internal-hooks.test.ts index bda0df831e8..bb048d5e7c1 100644 --- a/src/gateway/server-startup.plugin-internal-hooks.test.ts +++ b/src/gateway/server-startup.plugin-internal-hooks.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; import { createDefaultDeps } from "../cli/deps.js"; +import type { OpenClawConfig } from "../config/config.js"; import { clearInternalHooks, createInternalHookEvent, From cd9736f7adec2a356b16c9cbdec86f71594dc333 Mon Sep 17 00:00:00 2001 From: Rohit Amarnath <88762+ramarnat@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:56:04 -0500 Subject: [PATCH 4/5] resolve conflicts --- src/auto-reply/reply/route-reply.test.ts | 1 + src/gateway/server-plugins.test.ts | 1 + src/gateway/test-helpers.mocks.ts | 1 + src/plugins/loader.ts | 1 + src/test-utils/channel-plugins.ts | 1 + 5 files changed, 5 insertions(+) diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index ca369375870..eea2bc15004 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -67,6 +67,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => hooks: [], typedHooks: [], commands: [], + commandOptions: [], channels, providers: [], gatewayHandlers: {}, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 7fb34ff5efc..27d2372ca38 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -16,6 +16,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ typedHooks: [], channels: [], commands: [], + commandOptions: [], providers: [], gatewayHandlers: {}, httpHandlers: [], diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 19c6d2e91a4..a422644d97b 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -151,6 +151,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ cliRegistrars: [], services: [], commands: [], + commandOptions: [], diagnostics: [], }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index c60acba7396..3d3cf129542 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -176,6 +176,7 @@ function createPluginRecord(params: { cliCommands: [], services: [], commands: [], + commandOptions: [], httpHandlers: 0, hookCount: 0, configSchema: params.configSchema, diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 64e24deab52..6d2479c149e 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -25,6 +25,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl cliRegistrars: [], services: [], commands: [], + commandOptions: [], diagnostics: [], }); From 142fa9c9198eea0ed6a9d022313a6faf61416738 Mon Sep 17 00:00:00 2001 From: Rohit Amarnath <88762+ramarnat@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:51:39 -0500 Subject: [PATCH 5/5] fix(plugins): drop stale commandOptions fields --- src/auto-reply/reply/route-reply.test.ts | 1 - src/gateway/server-plugins.test.ts | 1 - src/gateway/test-helpers.mocks.ts | 1 - src/plugins/loader.ts | 1 - src/test-utils/channel-plugins.ts | 1 - 5 files changed, 5 deletions(-) diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index eea2bc15004..ca369375870 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -67,7 +67,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => hooks: [], typedHooks: [], commands: [], - commandOptions: [], channels, providers: [], gatewayHandlers: {}, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 27d2372ca38..7fb34ff5efc 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -16,7 +16,6 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ typedHooks: [], channels: [], commands: [], - commandOptions: [], providers: [], gatewayHandlers: {}, httpHandlers: [], diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index a422644d97b..19c6d2e91a4 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -151,7 +151,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({ cliRegistrars: [], services: [], commands: [], - commandOptions: [], diagnostics: [], }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 3d3cf129542..c60acba7396 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -176,7 +176,6 @@ function createPluginRecord(params: { cliCommands: [], services: [], commands: [], - commandOptions: [], httpHandlers: 0, hookCount: 0, configSchema: params.configSchema, diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 6d2479c149e..64e24deab52 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -25,7 +25,6 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl cliRegistrars: [], services: [], commands: [], - commandOptions: [], diagnostics: [], });