381 lines
10 KiB
TypeScript
381 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan a directory for hooks (subdirectories containing HOOK.md)
|
|
*/
|
|
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", // Extra dirs treated as workspace
|
|
});
|
|
});
|
|
const managedHooks = loadHooksFromDir({
|
|
dir: managedHooksDir,
|
|
source: "openclaw-managed",
|
|
});
|
|
const workspaceHooks = loadHooksFromDir({
|
|
dir: workspaceHooksDir,
|
|
source: "openclaw-workspace",
|
|
});
|
|
|
|
const merged = new Map<string, Hook>();
|
|
// Precedence: extra < bundled < managed < workspace (workspace wins)
|
|
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);
|
|
}
|