| import fs from "node:fs"; |
| import path from "node:path"; |
| import { MANIFEST_KEY } from "../compat/legacy-names.js"; |
| import type { OpenClawConfig } from "../config/config.js"; |
| import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; |
| import { createSubsystemLogger } from "../logging/subsystem.js"; |
| import { isPathInsideWithRealpath } from "../security/scan-paths.js"; |
| import { CONFIG_DIR, resolveUserPath } from "../utils.js"; |
| import { resolveBundledHooksDir } from "./bundled-dir.js"; |
| import { shouldIncludeHook } from "./config.js"; |
| import { |
| parseFrontmatter, |
| resolveOpenClawMetadata, |
| resolveHookInvocationPolicy, |
| } from "./frontmatter.js"; |
| import type { |
| Hook, |
| HookEligibilityContext, |
| HookEntry, |
| HookSnapshot, |
| HookSource, |
| ParsedHookFrontmatter, |
| } from "./types.js"; |
|
|
| type HookPackageManifest = { |
| name?: string; |
| } & Partial<Record<typeof MANIFEST_KEY, { hooks?: string[] }>>; |
| const log = createSubsystemLogger("hooks/workspace"); |
|
|
| function filterHookEntries( |
| entries: HookEntry[], |
| config?: OpenClawConfig, |
| eligibility?: HookEligibilityContext, |
| ): HookEntry[] { |
| return entries.filter((entry) => shouldIncludeHook({ entry, config, eligibility })); |
| } |
|
|
| function readHookPackageManifest(dir: string): HookPackageManifest | null { |
| const manifestPath = path.join(dir, "package.json"); |
| const raw = readBoundaryFileUtf8({ |
| absolutePath: manifestPath, |
| rootPath: dir, |
| boundaryLabel: "hook package directory", |
| }); |
| if (raw === null) { |
| return null; |
| } |
| try { |
| return JSON.parse(raw) as HookPackageManifest; |
| } catch { |
| return null; |
| } |
| } |
|
|
| function resolvePackageHooks(manifest: HookPackageManifest): string[] { |
| const raw = manifest[MANIFEST_KEY]?.hooks; |
| if (!Array.isArray(raw)) { |
| return []; |
| } |
| return raw.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); |
| } |
|
|
| function resolveContainedDir(baseDir: string, targetDir: string): string | null { |
| const base = path.resolve(baseDir); |
| const resolved = path.resolve(baseDir, targetDir); |
| if ( |
| !isPathInsideWithRealpath(base, resolved, { |
| requireRealpath: true, |
| }) |
| ) { |
| return null; |
| } |
| return resolved; |
| } |
|
|
| function loadHookFromDir(params: { |
| hookDir: string; |
| source: HookSource; |
| pluginId?: string; |
| nameHint?: string; |
| }): Hook | null { |
| const hookMdPath = path.join(params.hookDir, "HOOK.md"); |
| const content = readBoundaryFileUtf8({ |
| absolutePath: hookMdPath, |
| rootPath: params.hookDir, |
| boundaryLabel: "hook directory", |
| }); |
| if (content === null) { |
| return null; |
| } |
| try { |
| const frontmatter = parseFrontmatter(content); |
|
|
| const name = frontmatter.name || params.nameHint || path.basename(params.hookDir); |
| const description = frontmatter.description || ""; |
|
|
| const handlerCandidates = ["handler.ts", "handler.js", "index.ts", "index.js"]; |
| let handlerPath: string | undefined; |
| for (const candidate of handlerCandidates) { |
| const candidatePath = path.join(params.hookDir, candidate); |
| const safeCandidatePath = resolveBoundaryFilePath({ |
| absolutePath: candidatePath, |
| rootPath: params.hookDir, |
| boundaryLabel: "hook directory", |
| }); |
| if (safeCandidatePath) { |
| handlerPath = safeCandidatePath; |
| break; |
| } |
| } |
|
|
| if (!handlerPath) { |
| log.warn(`Hook "${name}" has HOOK.md but no handler file in ${params.hookDir}`); |
| return null; |
| } |
|
|
| return { |
| name, |
| description, |
| source: params.source, |
| pluginId: params.pluginId, |
| filePath: hookMdPath, |
| baseDir: params.hookDir, |
| handlerPath, |
| }; |
| } catch (err) { |
| const message = err instanceof Error ? (err.stack ?? err.message) : String(err); |
| log.warn(`Failed to load hook from ${params.hookDir}: ${message}`); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| function loadHooksFromDir(params: { dir: string; source: HookSource; pluginId?: string }): Hook[] { |
| const { dir, source, pluginId } = params; |
|
|
| if (!fs.existsSync(dir)) { |
| return []; |
| } |
|
|
| const stat = fs.statSync(dir); |
| if (!stat.isDirectory()) { |
| return []; |
| } |
|
|
| const hooks: Hook[] = []; |
| const entries = fs.readdirSync(dir, { withFileTypes: true }); |
|
|
| for (const entry of entries) { |
| if (!entry.isDirectory()) { |
| continue; |
| } |
|
|
| const hookDir = path.join(dir, entry.name); |
| const manifest = readHookPackageManifest(hookDir); |
| const packageHooks = manifest ? resolvePackageHooks(manifest) : []; |
|
|
| if (packageHooks.length > 0) { |
| for (const hookPath of packageHooks) { |
| const resolvedHookDir = resolveContainedDir(hookDir, hookPath); |
| if (!resolvedHookDir) { |
| log.warn( |
| `Ignoring out-of-package hook path "${hookPath}" in ${hookDir} (must be within package directory)`, |
| ); |
| continue; |
| } |
| const hook = loadHookFromDir({ |
| hookDir: resolvedHookDir, |
| source, |
| pluginId, |
| nameHint: path.basename(resolvedHookDir), |
| }); |
| if (hook) { |
| hooks.push(hook); |
| } |
| } |
| continue; |
| } |
|
|
| const hook = loadHookFromDir({ |
| hookDir, |
| source, |
| pluginId, |
| nameHint: entry.name, |
| }); |
| if (hook) { |
| hooks.push(hook); |
| } |
| } |
|
|
| return hooks; |
| } |
|
|
| export function loadHookEntriesFromDir(params: { |
| dir: string; |
| source: HookSource; |
| pluginId?: string; |
| }): HookEntry[] { |
| const hooks = loadHooksFromDir({ |
| dir: params.dir, |
| source: params.source, |
| pluginId: params.pluginId, |
| }); |
| return hooks.map((hook) => { |
| let frontmatter: ParsedHookFrontmatter = {}; |
| const raw = readBoundaryFileUtf8({ |
| absolutePath: hook.filePath, |
| rootPath: hook.baseDir, |
| boundaryLabel: "hook directory", |
| }); |
| if (raw !== null) { |
| frontmatter = parseFrontmatter(raw); |
| } |
| const entry: HookEntry = { |
| hook: { |
| ...hook, |
| source: params.source, |
| pluginId: params.pluginId, |
| }, |
| frontmatter, |
| metadata: resolveOpenClawMetadata(frontmatter), |
| invocation: resolveHookInvocationPolicy(frontmatter), |
| }; |
| return entry; |
| }); |
| } |
|
|
| function loadHookEntries( |
| workspaceDir: string, |
| opts?: { |
| config?: OpenClawConfig; |
| managedHooksDir?: string; |
| bundledHooksDir?: string; |
| }, |
| ): HookEntry[] { |
| const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks"); |
| const workspaceHooksDir = path.join(workspaceDir, "hooks"); |
| const bundledHooksDir = opts?.bundledHooksDir ?? resolveBundledHooksDir(); |
| const extraDirsRaw = opts?.config?.hooks?.internal?.load?.extraDirs ?? []; |
| const extraDirs = extraDirsRaw |
| .map((d) => (typeof d === "string" ? d.trim() : "")) |
| .filter(Boolean); |
|
|
| const bundledHooks = bundledHooksDir |
| ? loadHooksFromDir({ |
| dir: bundledHooksDir, |
| source: "openclaw-bundled", |
| }) |
| : []; |
| const extraHooks = extraDirs.flatMap((dir) => { |
| const resolved = resolveUserPath(dir); |
| return loadHooksFromDir({ |
| dir: resolved, |
| source: "openclaw-workspace", |
| }); |
| }); |
| const managedHooks = loadHooksFromDir({ |
| dir: managedHooksDir, |
| source: "openclaw-managed", |
| }); |
| const workspaceHooks = loadHooksFromDir({ |
| dir: workspaceHooksDir, |
| source: "openclaw-workspace", |
| }); |
|
|
| const merged = new Map<string, Hook>(); |
| |
| for (const hook of extraHooks) { |
| merged.set(hook.name, hook); |
| } |
| for (const hook of bundledHooks) { |
| merged.set(hook.name, hook); |
| } |
| for (const hook of managedHooks) { |
| merged.set(hook.name, hook); |
| } |
| for (const hook of workspaceHooks) { |
| merged.set(hook.name, hook); |
| } |
|
|
| return Array.from(merged.values()).map((hook) => { |
| let frontmatter: ParsedHookFrontmatter = {}; |
| const raw = readBoundaryFileUtf8({ |
| absolutePath: hook.filePath, |
| rootPath: hook.baseDir, |
| boundaryLabel: "hook directory", |
| }); |
| if (raw !== null) { |
| frontmatter = parseFrontmatter(raw); |
| } |
| return { |
| hook, |
| frontmatter, |
| metadata: resolveOpenClawMetadata(frontmatter), |
| invocation: resolveHookInvocationPolicy(frontmatter), |
| }; |
| }); |
| } |
|
|
| export function buildWorkspaceHookSnapshot( |
| workspaceDir: string, |
| opts?: { |
| config?: OpenClawConfig; |
| managedHooksDir?: string; |
| bundledHooksDir?: string; |
| entries?: HookEntry[]; |
| eligibility?: HookEligibilityContext; |
| snapshotVersion?: number; |
| }, |
| ): HookSnapshot { |
| const hookEntries = opts?.entries ?? loadHookEntries(workspaceDir, opts); |
| const eligible = filterHookEntries(hookEntries, opts?.config, opts?.eligibility); |
|
|
| return { |
| hooks: eligible.map((entry) => ({ |
| name: entry.hook.name, |
| events: entry.metadata?.events ?? [], |
| })), |
| resolvedHooks: eligible.map((entry) => entry.hook), |
| version: opts?.snapshotVersion, |
| }; |
| } |
|
|
| export function loadWorkspaceHookEntries( |
| workspaceDir: string, |
| opts?: { |
| config?: OpenClawConfig; |
| managedHooksDir?: string; |
| bundledHooksDir?: string; |
| }, |
| ): HookEntry[] { |
| return loadHookEntries(workspaceDir, opts); |
| } |
|
|
| function readBoundaryFileUtf8(params: { |
| absolutePath: string; |
| rootPath: string; |
| boundaryLabel: string; |
| }): string | null { |
| return withOpenedBoundaryFileSync(params, (opened) => { |
| try { |
| return fs.readFileSync(opened.fd, "utf-8"); |
| } catch { |
| return null; |
| } |
| }); |
| } |
|
|
| function withOpenedBoundaryFileSync<T>( |
| params: { |
| absolutePath: string; |
| rootPath: string; |
| boundaryLabel: string; |
| }, |
| read: (opened: { fd: number; path: string }) => T, |
| ): T | null { |
| const opened = openBoundaryFileSync({ |
| absolutePath: params.absolutePath, |
| rootPath: params.rootPath, |
| boundaryLabel: params.boundaryLabel, |
| }); |
| if (!opened.ok) { |
| return null; |
| } |
| try { |
| return read({ fd: opened.fd, path: opened.path }); |
| } finally { |
| fs.closeSync(opened.fd); |
| } |
| } |
|
|
| function resolveBoundaryFilePath(params: { |
| absolutePath: string; |
| rootPath: string; |
| boundaryLabel: string; |
| }): string | null { |
| return withOpenedBoundaryFileSync(params, (opened) => opened.path); |
| } |
|
|