openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
226 lines (225 loc) • 8.83 kB
JavaScript
import { t as sanitizeForLog } from "./ansi-BI9w76Cm.js";
import { i as formatErrorMessage } from "./errors-BXgSefBE.js";
import { n as resolveGlobalSingleton } from "./global-singleton-PwlQSEal.js";
import { r as openRootFile } from "./root-file-jRMCpJW4.js";
import { t as createSubsystemLogger } from "./subsystem-BzXSmsuh.js";
import "./boundary-file-read-CBe_wA_B.js";
import { f as registerInternalHook, h as unregisterInternalHook } from "./internal-hooks-Bq6_9FFu.js";
import { n as shouldIncludeHook } from "./config-BKOvQE9K.js";
import { n as resolveFunctionModuleExport } from "./module-loader-BF97Ap2W.js";
import { t as loadWorkspaceHookEntries } from "./workspace-Cj3anEq4.js";
import { n as resolveConfiguredInternalHookNames, r as getLegacyInternalHookHandlers, t as hasConfiguredInternalHooks } from "./configured-pV8SaeM2.js";
import { pathToFileURL } from "node:url";
import fs from "node:fs";
import path from "node:path";
//#region src/hooks/import-url.ts
/**
* Build an import URL for a hook handler module.
*
* Bundled hooks (shipped in dist/) are immutable between installs, so they
* can be imported without a cache-busting suffix — letting V8 reuse its
* module cache across gateway restarts.
*
* Workspace, managed, and plugin hooks may be edited by the user between
* restarts. For those we append `?t=<mtime>&s=<size>` so the module key
* reflects on-disk changes while staying stable for unchanged files.
*/
/**
* Sources whose handler files never change between `npm install` runs.
* Imports from these sources skip cache busting entirely.
*/
const IMMUTABLE_SOURCES = new Set(["openclaw-bundled"]);
function buildImportUrl(handlerPath, source) {
const base = pathToFileURL(handlerPath).href;
if (IMMUTABLE_SOURCES.has(source)) return base;
try {
const { mtimeMs, size } = fs.statSync(handlerPath);
return `${base}?t=${mtimeMs}&s=${size}`;
} catch {
return `${base}?t=${Date.now()}`;
}
}
//#endregion
//#region src/hooks/loader.ts
/**
* Dynamic loader for hook handlers
*
* Loads hook handlers from external modules based on configuration
* and from directory-based discovery (bundled, managed, workspace)
*/
const log = createSubsystemLogger("hooks:loader");
const loadedHookRegistrations = resolveGlobalSingleton(Symbol.for("openclaw.loadedInternalHookRegistrations"), () => []);
function safeLogValue(value) {
return sanitizeForLog(value);
}
function isNonEmptyRelativePathInsideRoot(relativePath) {
return relativePath !== "" && relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath);
}
function maybeWarnTrustedHookSource(source) {
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.");
}
function resetLoadedInternalHooks() {
while (loadedHookRegistrations.length > 0) {
const registration = loadedHookRegistrations.pop();
if (!registration) continue;
unregisterInternalHook(registration.event, registration.handler);
}
}
/**
* 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 getRuntimeConfig();
* const workspaceDir = resolveAgentWorkspaceDir(config, agentId);
* const count = await loadInternalHooks(config, workspaceDir);
* console.log(`Loaded ${count} hook handlers`);
* ```
*/
async function loadInternalHooks(cfg, workspaceDir, opts) {
resetLoadedInternalHooks();
if (!hasConfiguredInternalHooks(cfg)) return 0;
let loadedCount = 0;
const configuredNames = resolveConfiguredInternalHookNames(cfg);
try {
const eligible = loadWorkspaceHookEntries(workspaceDir, {
config: cfg,
managedHooksDir: opts?.managedHooksDir,
bundledHooksDir: opts?.bundledHooksDir
}).filter((entry) => {
if (configuredNames && !configuredNames.has(entry.hook.name)) return false;
return shouldIncludeHook({
entry,
config: cfg
});
});
for (const entry of eligible) 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 openRootFile({
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 mod = await import(buildImportUrl(safeHandlerPath, entry.hook.source));
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;
}
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);
loadedHookRegistrations.push({
event,
handler
});
}
log.debug(`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(formatErrorMessage(err))}`);
}
} catch (err) {
log.error(`Failed to load directory-based hooks: ${safeLogValue(formatErrorMessage(err))}`);
}
const handlers = getLegacyInternalHookHandlers(cfg);
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;
}
if (!isNonEmptyRelativePathInsideRoot(path.relative(baseDirReal, modulePathSafe))) {
log.error(`Handler module path must stay within workspaceDir: ${safeLogValue(rawModule)}`);
continue;
}
const opened = await openRootFile({
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 mod = await import(buildImportUrl(safeModulePath, "openclaw-workspace"));
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);
loadedHookRegistrations.push({
event: handlerConfig.event,
handler
});
log.debug(`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(formatErrorMessage(err))}`);
}
return loadedCount;
}
function resolveExistingRealpath(value) {
try {
return fs.realpathSync(value);
} catch {
return null;
}
}
//#endregion
export { loadInternalHooks };