/** * Dynamic loader for hook handlers * * Loads hook handlers from external modules based on configuration * and from directory-based discovery (bundled, managed, workspace) */ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { openBoundaryFile } from "../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveHookConfig } from "./config.js"; import { shouldIncludeHook } from "./config.js"; import { buildImportUrl } from "./import-url.js"; import type { InternalHookHandler } from "./internal-hooks.js"; import { registerInternalHook } from "./internal-hooks.js"; import { resolveFunctionModuleExport } from "./module-loader.js"; import { loadWorkspaceHookEntries } from "./workspace.js"; const log = createSubsystemLogger("hooks:loader"); function safeLogValue(value: string): string { return sanitizeForLog(value); } function maybeWarnTrustedHookSource(source: string): void { if (source === "openclaw-workspace") { log.warn( "Loading workspace hook code into the gateway process. Workspace hooks are trusted local code.", ); return; } if (source === "openclaw-managed") { log.warn( "Loading managed hook code into the gateway process. Managed hooks are trusted local code.", ); } } /** * Load and register all hook handlers * * Loads hooks from both: * 1. Directory-based discovery (bundled, managed, workspace) * 2. Legacy config handlers (backwards compatibility) * * @param cfg - OpenClaw configuration * @param workspaceDir - Workspace directory for hook discovery * @returns Number of handlers successfully loaded * * @example * ```ts * const config = await loadConfig(); * const workspaceDir = resolveAgentWorkspaceDir(config, agentId); * const count = await loadInternalHooks(config, workspaceDir); * console.log(`Loaded ${count} hook handlers`); * ``` */ export async function loadInternalHooks( cfg: OpenClawConfig, workspaceDir: string, opts?: { managedHooksDir?: string; bundledHooksDir?: string; }, ): Promise { // Check if hooks are enabled if (!cfg.hooks?.internal?.enabled) { return 0; } let loadedCount = 0; // 1. Load hooks from directories (new system) try { const hookEntries = loadWorkspaceHookEntries(workspaceDir, { config: cfg, managedHooksDir: opts?.managedHooksDir, bundledHooksDir: opts?.bundledHooksDir, }); // Filter by eligibility const eligible = hookEntries.filter((entry) => shouldIncludeHook({ entry, config: cfg })); for (const entry of eligible) { const hookConfig = resolveHookConfig(cfg, entry.hook.name); // Skip if explicitly disabled in config if (hookConfig?.enabled === false) { continue; } try { const hookBaseDir = resolveExistingRealpath(entry.hook.baseDir); if (!hookBaseDir) { log.error( `Hook '${safeLogValue(entry.hook.name)}' base directory is no longer readable: ${safeLogValue(entry.hook.baseDir)}`, ); continue; } const opened = await openBoundaryFile({ absolutePath: entry.hook.handlerPath, rootPath: hookBaseDir, boundaryLabel: "hook directory", }); if (!opened.ok) { log.error( `Hook '${safeLogValue(entry.hook.name)}' handler path fails boundary checks: ${safeLogValue(entry.hook.handlerPath)}`, ); continue; } const safeHandlerPath = opened.path; fs.closeSync(opened.fd); maybeWarnTrustedHookSource(entry.hook.source); // Import handler module — only cache-bust mutable (workspace/managed) hooks const importUrl = buildImportUrl(safeHandlerPath, entry.hook.source); const mod = (await import(importUrl)) as Record; // Get handler function (default or named export) const exportName = entry.metadata?.export ?? "default"; const handler = resolveFunctionModuleExport({ mod, exportName, }); if (!handler) { log.error( `Handler '${safeLogValue(exportName)}' from ${safeLogValue(entry.hook.name)} is not a function`, ); continue; } // Register for all events listed in metadata const events = entry.metadata?.events ?? []; if (events.length === 0) { log.warn(`Hook '${safeLogValue(entry.hook.name)}' has no events defined in metadata`); continue; } for (const event of events) { registerInternalHook(event, handler); } log.info( `Registered hook: ${safeLogValue(entry.hook.name)} -> ${events.map((event) => safeLogValue(event)).join(", ")}${exportName !== "default" ? ` (export: ${safeLogValue(exportName)})` : ""}`, ); loadedCount++; } catch (err) { log.error( `Failed to load hook ${safeLogValue(entry.hook.name)}: ${safeLogValue(err instanceof Error ? err.message : String(err))}`, ); } } } catch (err) { log.error( `Failed to load directory-based hooks: ${safeLogValue(err instanceof Error ? err.message : String(err))}`, ); } // 2. Load legacy config handlers (backwards compatibility) const handlers = cfg.hooks.internal.handlers ?? []; for (const handlerConfig of handlers) { try { // Legacy handler paths: keep them workspace-relative. const rawModule = handlerConfig.module.trim(); if (!rawModule) { log.error("Handler module path is empty"); continue; } if (path.isAbsolute(rawModule)) { log.error( `Handler module path must be workspace-relative (got absolute path): ${safeLogValue(rawModule)}`, ); continue; } const baseDir = path.resolve(workspaceDir); const modulePath = path.resolve(baseDir, rawModule); const baseDirReal = resolveExistingRealpath(baseDir); if (!baseDirReal) { log.error( `Workspace directory is no longer readable while loading hooks: ${safeLogValue(baseDir)}`, ); continue; } const modulePathSafe = resolveExistingRealpath(modulePath); if (!modulePathSafe) { log.error( `Handler module path could not be resolved with realpath: ${safeLogValue(rawModule)}`, ); continue; } const rel = path.relative(baseDirReal, modulePathSafe); if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { log.error(`Handler module path must stay within workspaceDir: ${safeLogValue(rawModule)}`); continue; } const opened = await openBoundaryFile({ absolutePath: modulePathSafe, rootPath: baseDirReal, boundaryLabel: "workspace directory", }); if (!opened.ok) { log.error( `Handler module path fails boundary checks under workspaceDir: ${safeLogValue(rawModule)}`, ); continue; } const safeModulePath = opened.path; fs.closeSync(opened.fd); log.warn( `Loading legacy internal hook module from workspace path ${safeLogValue(rawModule)}. Legacy hook modules are trusted local code.`, ); // Legacy handlers are always workspace-relative, so use mtime-based cache busting const importUrl = buildImportUrl(safeModulePath, "openclaw-workspace"); const mod = (await import(importUrl)) as Record; // Get the handler function const exportName = handlerConfig.export ?? "default"; const handler = resolveFunctionModuleExport({ mod, exportName, }); if (!handler) { log.error( `Handler '${safeLogValue(exportName)}' from ${safeLogValue(modulePath)} is not a function`, ); continue; } registerInternalHook(handlerConfig.event, handler); log.info( `Registered hook (legacy): ${safeLogValue(handlerConfig.event)} -> ${safeLogValue(modulePath)}${exportName !== "default" ? `#${safeLogValue(exportName)}` : ""}`, ); loadedCount++; } catch (err) { log.error( `Failed to load hook handler from ${safeLogValue(handlerConfig.module)}: ${safeLogValue(err instanceof Error ? err.message : String(err))}`, ); } } return loadedCount; } function resolveExistingRealpath(value: string): string | null { try { return fs.realpathSync(value); } catch { return null; } }