File size: 6,967 Bytes
d197cf3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | import fs from "node:fs";
import { sanitizeForLog as safeLogValue } from "../../packages/terminal-core/src/ansi.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { openRootFile } from "../infra/boundary-file-read.js";
import { safeRealpathSync } from "../infra/boundary-path.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { isHookLoadable, isHookNameSelected, resolveInternalHookSelection } from "./configured.js";
import { buildImportUrl } from "./import-url.js";
import { isKnownInternalHookEventKey } from "./internal-hook-types.js";
import {
type InternalHookHandler,
registerInternalHook,
setInternalHooksEnabled,
unregisterInternalHook,
} from "./internal-hooks.js";
import { resolveFunctionModuleExport } from "./module-loader.js";
import type { HookPolicyEntry } from "./types.js";
import { prepareWorkspaceHookEntries, type HookSourceFact } from "./workspace.js";
const log = createSubsystemLogger("hooks:loader");
// Configured hooks belong to the Gateway, not to any one plugin registry generation.
type HookGeneration = {
registrations: Array<{ event: string; handler: InternalHookHandler }>;
discovery?: { sources: HookSourceFact[]; declaredNames: Set<string> };
committed?: true;
};
const hookOwner = resolveGlobalSingleton<{ generation: HookGeneration }>(
Symbol.for("openclaw.loadedInternalHookRegistrations"),
() => ({ generation: { registrations: [] } }),
() => resetLoadedInternalHooks(),
);
function resetLoadedInternalHooks(): void {
for (const { event, handler } of hookOwner.generation.registrations) {
unregisterInternalHook(event, handler);
}
hookOwner.generation = { registrations: [] };
}
export type PreparedInternalHooks = {
loadedCount: number;
commit: (options?: { initial?: boolean }) => boolean;
};
/** Imports candidate handlers without publishing registrations or changing the dispatch gate. */
export async function prepareInternalHooks(
cfg: OpenClawConfig,
workspaceDir: string,
opts?: {
managedHooksDir?: string;
bundledHooksDir?: string;
failureMode?: "atomic" | "best-effort";
},
): Promise<PreparedInternalHooks> {
const enabled = cfg.hooks?.internal?.enabled !== false;
const previousGeneration = hookOwner.generation;
const registrations: HookGeneration["registrations"] = [];
let loadedCount = 0;
const selection = resolveInternalHookSelection(cfg);
const shouldLoadHook = (entry: HookPolicyEntry) =>
isHookLoadable({ entry, config: cfg, names: selection.names });
const discovery = selection.configured
? prepareWorkspaceHookEntries(workspaceDir, {
config: cfg,
managedHooksDir: opts?.managedHooksDir,
bundledHooksDir: opts?.bundledHooksDir,
...(opts?.failureMode !== "best-effort"
? {
requireValidHook: shouldLoadHook,
previousSources: previousGeneration.discovery?.sources.filter(
(entry) =>
!isHookNameSelected(previousGeneration.discovery?.declaredNames, entry) ||
isHookNameSelected(selection.declaredNames, entry),
),
}
: {}),
})
: { entries: [], sources: [] };
for (const entry of discovery.entries) {
if (!shouldLoadHook(entry)) {
continue;
}
try {
const hookBaseDir = safeRealpathSync(entry.hook.baseDir);
if (!hookBaseDir) {
throw new Error(`Hook base directory is no longer readable: ${entry.hook.baseDir}`);
}
const opened = await openRootFile({
absolutePath: entry.hook.handlerPath,
rootPath: hookBaseDir,
boundaryLabel: "hook directory",
});
if (!opened.ok) {
throw new Error(`Handler path fails boundary checks: ${entry.hook.handlerPath}`);
}
const safeHandlerPath = opened.path;
fs.closeSync(opened.fd);
if (entry.hook.source === "openclaw-workspace" || entry.hook.source === "openclaw-managed") {
log.warn(
`Loading ${entry.hook.source.slice("openclaw-".length)} hook code into the gateway process. Hooks are trusted local code.`,
);
}
// Only mutable workspace/managed modules are cache-busted; imports may run trusted code.
const mod = (await import(buildImportUrl(safeHandlerPath, entry.hook.source))) as Record<
string,
unknown
>;
const exportName = entry.metadata?.export ?? "default";
const handler = resolveFunctionModuleExport<InternalHookHandler>({ mod, exportName });
if (!handler) {
throw new Error(`Handler '${exportName}' is not a function`);
}
const events = entry.metadata?.events ?? [];
if (events.length === 0) {
throw new Error("Hook has no events defined in metadata");
}
// Plugins can emit custom keys, so unknown core events remain advisory.
const unknownEvents = events.filter((event) => !isKnownInternalHookEventKey(event));
if (unknownEvents.length > 0) {
log.warn(
`Hook '${safeLogValue(entry.hook.name)}' subscribes to event${unknownEvents.length === 1 ? "" : "s"} ` +
`${unknownEvents.map((event) => safeLogValue(event)).join(", ")} not emitted by OpenClaw core — ` +
`likely a typo; unless a plugin emits it, the hook never fires. ` +
`Known events: https://docs.openclaw.ai/automation/hooks`,
);
}
registrations.push(...events.map((event) => ({ event, handler })));
loadedCount++;
log.debug(
`Prepared hook: ${safeLogValue(entry.hook.name)} -> ${events.map((event) => safeLogValue(event)).join(", ")}${exportName !== "default" ? ` (export: ${safeLogValue(exportName)})` : ""}`,
);
} catch (error) {
const message = `Failed to load hook ${safeLogValue(entry.hook.name)}: ${safeLogValue(formatErrorMessage(error))}`;
if (opts?.failureMode !== "best-effort") {
throw new Error(message, { cause: error });
}
log.error(message);
}
}
return {
loadedCount,
commit({ initial = false } = {}) {
// Deferred startup must not overwrite a reload, or a later Gateway lifecycle.
if (
initial &&
(previousGeneration !== hookOwner.generation || previousGeneration.committed)
) {
return false;
}
// Publish synchronously so events see one complete generation; keep unrelated listeners.
resetLoadedInternalHooks();
for (const { event, handler } of registrations) {
registerInternalHook(event, handler);
}
hookOwner.generation = {
registrations,
discovery: { sources: discovery.sources, declaredNames: selection.declaredNames },
committed: true,
};
setInternalHooksEnabled(enabled);
return true;
},
};
}
|