210 lines
6.8 KiB
TypeScript
210 lines
6.8 KiB
TypeScript
/**
|
|
* 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 { 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");
|
|
|
|
/**
|
|
* 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<number> {
|
|
// 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 = safeRealpathOrResolve(entry.hook.baseDir);
|
|
const opened = await openBoundaryFile({
|
|
absolutePath: entry.hook.handlerPath,
|
|
rootPath: hookBaseDir,
|
|
boundaryLabel: "hook directory",
|
|
});
|
|
if (!opened.ok) {
|
|
log.error(
|
|
`Hook '${entry.hook.name}' handler path fails boundary checks: ${entry.hook.handlerPath}`,
|
|
);
|
|
continue;
|
|
}
|
|
const safeHandlerPath = opened.path;
|
|
fs.closeSync(opened.fd);
|
|
|
|
// Import handler module — only cache-bust mutable (workspace/managed) hooks
|
|
const importUrl = buildImportUrl(safeHandlerPath, entry.hook.source);
|
|
const mod = (await import(importUrl)) as Record<string, unknown>;
|
|
|
|
// Get handler function (default or named export)
|
|
const exportName = entry.metadata?.export ?? "default";
|
|
const handler = resolveFunctionModuleExport<InternalHookHandler>({
|
|
mod,
|
|
exportName,
|
|
});
|
|
|
|
if (!handler) {
|
|
log.error(`Handler '${exportName}' from ${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 '${entry.hook.name}' has no events defined in metadata`);
|
|
continue;
|
|
}
|
|
|
|
for (const event of events) {
|
|
registerInternalHook(event, handler);
|
|
}
|
|
|
|
log.info(
|
|
`Registered hook: ${entry.hook.name} -> ${events.join(", ")}${exportName !== "default" ? ` (export: ${exportName})` : ""}`,
|
|
);
|
|
loadedCount++;
|
|
} catch (err) {
|
|
log.error(
|
|
`Failed to load hook ${entry.hook.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.error(
|
|
`Failed to load directory-based hooks: ${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): ${rawModule}`,
|
|
);
|
|
continue;
|
|
}
|
|
const baseDir = path.resolve(workspaceDir);
|
|
const modulePath = path.resolve(baseDir, rawModule);
|
|
const baseDirReal = safeRealpathOrResolve(baseDir);
|
|
const modulePathSafe = safeRealpathOrResolve(modulePath);
|
|
const rel = path.relative(baseDir, modulePath);
|
|
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
log.error(`Handler module path must stay within workspaceDir: ${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: ${rawModule}`);
|
|
continue;
|
|
}
|
|
const safeModulePath = opened.path;
|
|
fs.closeSync(opened.fd);
|
|
|
|
// 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<string, unknown>;
|
|
|
|
// Get the handler function
|
|
const exportName = handlerConfig.export ?? "default";
|
|
const handler = resolveFunctionModuleExport<InternalHookHandler>({
|
|
mod,
|
|
exportName,
|
|
});
|
|
|
|
if (!handler) {
|
|
log.error(`Handler '${exportName}' from ${modulePath} is not a function`);
|
|
continue;
|
|
}
|
|
|
|
registerInternalHook(handlerConfig.event, handler);
|
|
log.info(
|
|
`Registered hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== "default" ? `#${exportName}` : ""}`,
|
|
);
|
|
loadedCount++;
|
|
} catch (err) {
|
|
log.error(
|
|
`Failed to load hook handler from ${handlerConfig.module}: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return loadedCount;
|
|
}
|
|
|
|
function safeRealpathOrResolve(value: string): string {
|
|
try {
|
|
return fs.realpathSync(value);
|
|
} catch {
|
|
return path.resolve(value);
|
|
}
|
|
}
|