| |
| |
| |
| |
| |
| |
|
|
| 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.", |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function loadInternalHooks( |
| cfg: OpenClawConfig, |
| workspaceDir: string, |
| opts?: { |
| managedHooksDir?: string; |
| bundledHooksDir?: string; |
| }, |
| ): Promise<number> { |
| |
| if (!cfg.hooks?.internal?.enabled) { |
| return 0; |
| } |
|
|
| let loadedCount = 0; |
|
|
| |
| try { |
| const hookEntries = loadWorkspaceHookEntries(workspaceDir, { |
| config: cfg, |
| managedHooksDir: opts?.managedHooksDir, |
| bundledHooksDir: opts?.bundledHooksDir, |
| }); |
|
|
| |
| const eligible = hookEntries.filter((entry) => shouldIncludeHook({ entry, config: cfg })); |
|
|
| for (const entry of eligible) { |
| const hookConfig = resolveHookConfig(cfg, entry.hook.name); |
|
|
| |
| 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); |
|
|
| |
| const importUrl = buildImportUrl(safeHandlerPath, entry.hook.source); |
| const mod = (await import(importUrl)) as Record<string, unknown>; |
|
|
| |
| const exportName = entry.metadata?.export ?? "default"; |
| const handler = resolveFunctionModuleExport<InternalHookHandler>({ |
| mod, |
| exportName, |
| }); |
|
|
| if (!handler) { |
| log.error( |
| `Handler '${safeLogValue(exportName)}' from ${safeLogValue(entry.hook.name)} is not a function`, |
| ); |
| continue; |
| } |
|
|
| |
| 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))}`, |
| ); |
| } |
|
|
| |
| const handlers = cfg.hooks.internal.handlers ?? []; |
| for (const handlerConfig of handlers) { |
| try { |
| |
| 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.`, |
| ); |
|
|
| |
| const importUrl = buildImportUrl(safeModulePath, "openclaw-workspace"); |
| const mod = (await import(importUrl)) as Record<string, unknown>; |
|
|
| |
| const exportName = handlerConfig.export ?? "default"; |
| const handler = resolveFunctionModuleExport<InternalHookHandler>({ |
| 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; |
| } |
| } |
|
|