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..bb048d5e7c1 --- /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 { createDefaultDeps } from "../cli/deps.js"; +import type { OpenClawConfig } from "../config/config.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 7a4c18b6593..be65840d65a 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -22,6 +22,7 @@ import { import { formatConfigIssueLines } from "../config/issue-format.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, @@ -557,6 +558,10 @@ export async function startGatewayServer( env: process.env, }); 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(); let pluginRegistry = emptyPluginRegistry; let baseGatewayMethods = baseMethods;