refactor(hooks): unify hook policy resolution

This commit is contained in:
Peter Steinberger 2026-03-22 09:59:16 -07:00
parent c96c319db3
commit 5cb2f45585
No known key found for this signature in database
10 changed files with 318 additions and 117 deletions

View File

@ -20,9 +20,10 @@ const report: HookStatusReport = {
homepage: "https://docs.openclaw.ai/automation/hooks#session-memory",
events: ["command:new"],
always: false,
disabled: false,
eligible: true,
enabledByConfig: true,
requirementsSatisfied: true,
loadable: true,
blockedReason: undefined,
managedByPlugin: false,
...createEmptyInstallChecks(),
},
@ -59,9 +60,10 @@ describe("hooks cli formatting", () => {
homepage: undefined,
events: ["command:new"],
always: false,
disabled: false,
eligible: true,
enabledByConfig: true,
requirementsSatisfied: true,
loadable: true,
blockedReason: undefined,
managedByPlugin: true,
...createEmptyInstallChecks(),
},

View File

@ -16,6 +16,7 @@ import {
resolveHookInstallDir,
} from "../hooks/install.js";
import { recordHookInstall } from "../hooks/installs.js";
import { resolveHookEntries } from "../hooks/policy.js";
import type { HookEntry } from "../hooks/types.js";
import { loadWorkspaceHookEntries } from "../hooks/workspace.js";
import { resolveArchiveKind } from "../infra/archive.js";
@ -53,14 +54,7 @@ export type HooksUpdateOptions = {
};
function mergeHookEntries(pluginEntries: HookEntry[], workspaceEntries: HookEntry[]): HookEntry[] {
const merged = new Map<string, HookEntry>();
for (const entry of pluginEntries) {
merged.set(entry.hook.name, entry);
}
for (const entry of workspaceEntries) {
merged.set(entry.hook.name, entry);
}
return Array.from(merged.values());
return resolveHookEntries([...pluginEntries, ...workspaceEntries]);
}
function buildHooksReport(config: OpenClawConfig): HookStatusReport {
@ -86,10 +80,7 @@ function resolveHookForToggle(
`Hook "${hookName}" is managed by plugin "${hook.pluginId ?? "unknown"}" and cannot be enabled/disabled.`,
);
}
if (opts?.requireEligible && !hook.eligible) {
if (hook.disabled && hook.requirementsSatisfied) {
return hook;
}
if (opts?.requireEligible && !hook.requirementsSatisfied) {
throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`);
}
return hook;
@ -120,10 +111,10 @@ function buildConfigWithHookEnabled(params: {
}
function formatHookStatus(hook: HookStatusEntry): string {
if (hook.eligible) {
if (hook.loadable) {
return theme.success("✓ ready");
}
if (hook.disabled) {
if (!hook.enabledByConfig) {
return theme.warn("⏸ disabled");
}
return theme.error("✗ missing");
@ -245,7 +236,7 @@ function enableInternalHookEntries(config: OpenClawConfig, hookNames: string[]):
* Format the hooks list output
*/
export function formatHooksList(report: HookStatusReport, opts: HooksListOptions): string {
const hooks = opts.eligible ? report.hooks.filter((h) => h.eligible) : report.hooks;
const hooks = opts.eligible ? report.hooks.filter((h) => h.loadable) : report.hooks;
if (opts.json) {
const jsonReport = {
@ -255,8 +246,12 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
name: h.name,
description: h.description,
emoji: h.emoji,
eligible: h.eligible,
disabled: h.disabled,
eligible: h.loadable,
disabled: !h.enabledByConfig,
enabledByConfig: h.enabledByConfig,
requirementsSatisfied: h.requirementsSatisfied,
loadable: h.loadable,
blockedReason: h.blockedReason,
source: h.source,
pluginId: h.pluginId,
events: h.events,
@ -275,7 +270,7 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
return message;
}
const eligible = hooks.filter((h) => h.eligible);
const eligible = hooks.filter((h) => h.loadable);
const tableWidth = getTerminalTableWidth();
const rows = hooks.map((hook) => {
const missing = formatHookMissingSummary(hook);
@ -330,14 +325,22 @@ export function formatHookInfo(
}
if (opts.json) {
return JSON.stringify(hook, null, 2);
return JSON.stringify(
{
...hook,
eligible: hook.loadable,
disabled: !hook.enabledByConfig,
},
null,
2,
);
}
const lines: string[] = [];
const emoji = hook.emoji ?? "🔗";
const status = hook.eligible
const status = hook.loadable
? theme.success("✓ Ready")
: hook.disabled
: !hook.enabledByConfig
? theme.warn("⏸ Disabled")
: theme.error("✗ Missing requirements");
@ -364,6 +367,9 @@ export function formatHookInfo(
if (hook.managedByPlugin) {
lines.push(theme.muted(" Managed by plugin; enable/disable via hooks CLI not available."));
}
if (hook.blockedReason) {
lines.push(`${theme.muted(" Blocked reason:")} ${hook.blockedReason}`);
}
// Requirements
const hasRequirements =
@ -420,8 +426,8 @@ export function formatHookInfo(
*/
export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptions): string {
if (opts.json) {
const eligible = report.hooks.filter((h) => h.eligible);
const notEligible = report.hooks.filter((h) => !h.eligible);
const eligible = report.hooks.filter((h) => h.loadable);
const notEligible = report.hooks.filter((h) => !h.loadable);
return JSON.stringify(
{
total: report.hooks.length,
@ -431,6 +437,7 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
eligible: eligible.map((h) => h.name),
notEligible: notEligible.map((h) => ({
name: h.name,
blockedReason: h.blockedReason,
missing: h.missing,
})),
},
@ -440,8 +447,8 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
);
}
const eligible = report.hooks.filter((h) => h.eligible);
const notEligible = report.hooks.filter((h) => !h.eligible);
const eligible = report.hooks.filter((h) => h.loadable);
const notEligible = report.hooks.filter((h) => !h.loadable);
const lines: string[] = [];
lines.push(theme.heading("Hooks Status"));
@ -455,8 +462,8 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
lines.push(theme.heading("Hooks not ready:"));
for (const hook of notEligible) {
const reasons = [];
if (hook.disabled) {
reasons.push("disabled");
if (hook.blockedReason && hook.blockedReason !== "missing requirements") {
reasons.push(hook.blockedReason);
}
if (hook.missing.bins.length > 0) {
reasons.push(`bins: ${hook.missing.bins.join(", ")}`);

View File

@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { HookStatusReport } from "../hooks/hooks-status.js";
import type { HookStatusEntry, HookStatusReport } from "../hooks/hooks-status.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { setupInternalHooks } from "./onboard-hooks.js";
@ -53,14 +53,17 @@ describe("onboard-hooks", () => {
},
eligible: boolean,
) => ({
blockedReason: (eligible
? undefined
: "missing requirements") as HookStatusEntry["blockedReason"],
...params,
source: "openclaw-bundled" as const,
pluginId: undefined,
homepage: undefined,
always: false,
disabled: false,
eligible,
enabledByConfig: eligible,
requirementsSatisfied: eligible,
loadable: eligible,
managedByPlugin: false,
requirements: {
bins: [],

View File

@ -25,7 +25,7 @@ export async function setupInternalHooks(
const report = buildWorkspaceHookStatus(workspaceDir, { config: cfg });
// Show every eligible hook so users can opt in during setup.
const eligibleHooks = report.hooks.filter((h) => h.eligible);
const eligibleHooks = report.hooks.filter((h) => h.loadable);
if (eligibleHooks.length === 0) {
await prompter.note(

View File

@ -6,7 +6,7 @@ import {
resolveConfigPath,
resolveRuntimePlatform,
} from "../shared/config-eval.js";
import { resolveHookKey } from "./frontmatter.js";
import { resolveHookConfig, resolveHookEnableState } from "./policy.js";
import type { HookEligibilityContext, HookEntry } from "./types.js";
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
@ -21,37 +21,7 @@ export function isConfigPathTruthy(config: OpenClawConfig | undefined, pathStr:
return isConfigPathTruthyWithDefaults(config, pathStr, DEFAULT_CONFIG_VALUES);
}
export function resolveHookConfig(
config: OpenClawConfig | undefined,
hookKey: string,
): HookConfig | undefined {
const hooks = config?.hooks?.internal?.entries;
if (!hooks || typeof hooks !== "object") {
return undefined;
}
const entry = (hooks as Record<string, HookConfig | undefined>)[hookKey];
if (!entry || typeof entry !== "object") {
return undefined;
}
return entry;
}
export function isHookDisabledByConfig(params: {
entry: HookEntry;
config?: OpenClawConfig;
hookConfig?: HookConfig;
}): boolean {
const { entry, config } = params;
const hookKey = resolveHookKey(entry.hook.name, entry);
const hookConfig = params.hookConfig ?? resolveHookConfig(config, hookKey);
if (entry.hook.source === "openclaw-plugin") {
return false;
}
if (hookConfig?.enabled === false) {
return true;
}
return entry.hook.source === "openclaw-workspace" && hookConfig?.enabled !== true;
}
export { resolveHookConfig };
function evaluateHookRuntimeEligibility(params: {
entry: HookEntry;
@ -83,8 +53,11 @@ export function shouldIncludeHook(params: {
eligibility?: HookEligibilityContext;
}): boolean {
const { entry, config, eligibility } = params;
const hookConfig = resolveHookConfig(config, resolveHookKey(entry.hook.name, entry));
if (isHookDisabledByConfig({ entry, config, hookConfig })) {
const hookConfig = resolveHookConfig(
config,
params.entry.metadata?.hookKey ?? params.entry.hook.name,
);
if (!resolveHookEnableState({ entry, config, hookConfig }).enabled) {
return false;
}

View File

@ -3,12 +3,13 @@ import type { OpenClawConfig } from "../config/config.js";
import { evaluateEntryRequirementsForCurrentPlatform } from "../shared/entry-status.js";
import type { RequirementConfigCheck, Requirements } from "../shared/requirements.js";
import { CONFIG_DIR } from "../utils.js";
import { hasBinary, isConfigPathTruthy } from "./config.js";
import {
hasBinary,
isConfigPathTruthy,
isHookDisabledByConfig,
resolveHookConfig,
} from "./config.js";
resolveHookEnableState,
resolveHookEntries,
type HookEnableStateReason,
} from "./policy.js";
import type { HookEligibilityContext, HookEntry, HookInstallSpec } from "./types.js";
import { loadWorkspaceHookEntries } from "./workspace.js";
@ -34,9 +35,10 @@ export type HookStatusEntry = {
homepage?: string;
events: string[];
always: boolean;
disabled: boolean;
eligible: boolean;
enabledByConfig: boolean;
requirementsSatisfied: boolean;
loadable: boolean;
blockedReason?: HookEnableStateReason | "missing requirements";
managedByPlugin: boolean;
requirements: Requirements;
missing: Requirements;
@ -90,7 +92,7 @@ function buildHookStatus(
const hookKey = resolveHookKey(entry);
const hookConfig = resolveHookConfig(config, hookKey);
const managedByPlugin = entry.hook.source === "openclaw-plugin";
const disabled = isHookDisabledByConfig({ entry, config, hookConfig });
const enableState = resolveHookEnableState({ entry, config, hookConfig });
const always = entry.metadata?.always === true;
const events = entry.metadata?.events ?? [];
const isEnvSatisfied = (envName: string) =>
@ -107,7 +109,10 @@ function buildHookStatus(
isConfigSatisfied,
});
const eligible = !disabled && requirementsSatisfied;
const enabledByConfig = enableState.enabled;
const loadable = enabledByConfig && requirementsSatisfied;
const blockedReason =
enableState.reason ?? (requirementsSatisfied ? undefined : "missing requirements");
return {
name: entry.hook.name,
@ -122,9 +127,10 @@ function buildHookStatus(
homepage,
events,
always,
disabled,
eligible,
enabledByConfig,
requirementsSatisfied,
loadable,
blockedReason,
managedByPlugin,
requirements: required,
missing,
@ -143,7 +149,9 @@ export function buildWorkspaceHookStatus(
},
): HookStatusReport {
const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks");
const hookEntries = opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts);
const hookEntries = resolveHookEntries(
opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts),
);
return {
workspaceDir,

View File

@ -11,7 +11,6 @@ import type { OpenClawConfig } from "../config/config.js";
import { openBoundaryFile } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { resolveHookConfig } from "./config.js";
import { shouldIncludeHook } from "./config.js";
import { buildImportUrl } from "./import-url.js";
import type { InternalHookHandler } from "./internal-hooks.js";
@ -85,13 +84,6 @@ export async function loadInternalHooks(
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 = resolveExistingRealpath(entry.hook.baseDir);
if (!hookBaseDir) {

90
src/hooks/policy.test.ts Normal file
View File

@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveHookEnableState, resolveHookEntries } from "./policy.js";
import type { HookEntry, HookSource } from "./types.js";
function makeHookEntry(name: string, source: HookSource): HookEntry {
return {
hook: {
name,
description: `${name} description`,
source,
filePath: `/tmp/${source}/${name}/HOOK.md`,
baseDir: `/tmp/${source}/${name}`,
handlerPath: `/tmp/${source}/${name}/handler.js`,
},
frontmatter: {
name,
},
metadata: {
events: ["command:new"],
},
invocation: {
enabled: true,
},
};
}
describe("hook policy", () => {
describe("resolveHookEnableState", () => {
it("keeps workspace hooks disabled by default", () => {
const entry = makeHookEntry("workspace-hook", "openclaw-workspace");
expect(resolveHookEnableState({ entry })).toEqual({
enabled: false,
reason: "workspace hook (disabled by default)",
});
});
it("allows workspace hooks when explicitly enabled", () => {
const entry = makeHookEntry("workspace-hook", "openclaw-workspace");
const config: OpenClawConfig = {
hooks: {
internal: {
entries: {
"workspace-hook": {
enabled: true,
},
},
},
},
};
expect(resolveHookEnableState({ entry, config })).toEqual({ enabled: true });
});
it("keeps plugin hooks enabled without local hook toggles", () => {
const entry = makeHookEntry("plugin-hook", "openclaw-plugin");
expect(resolveHookEnableState({ entry })).toEqual({ enabled: true });
});
});
describe("resolveHookEntries", () => {
it("lets managed hooks override bundled and plugin hooks", () => {
const bundled = makeHookEntry("shared", "openclaw-bundled");
const plugin = makeHookEntry("shared", "openclaw-plugin");
const managed = makeHookEntry("shared", "openclaw-managed");
const resolved = resolveHookEntries([bundled, plugin, managed]);
expect(resolved).toHaveLength(1);
expect(resolved[0]?.hook.source).toBe("openclaw-managed");
});
it("prevents workspace hooks from overriding non-workspace hooks", () => {
const managed = makeHookEntry("shared", "openclaw-managed");
const workspace = makeHookEntry("shared", "openclaw-workspace");
const resolved = resolveHookEntries([managed, workspace]);
expect(resolved).toHaveLength(1);
expect(resolved[0]?.hook.source).toBe("openclaw-managed");
});
it("keeps later workspace entries for the same source/name", () => {
const first = makeHookEntry("shared", "openclaw-workspace");
const second = makeHookEntry("shared", "openclaw-workspace");
second.hook.handlerPath = "/tmp/openclaw-workspace/shared/handler-2.js";
const resolved = resolveHookEntries([first, second]);
expect(resolved).toHaveLength(1);
expect(resolved[0]?.hook.handlerPath).toContain("handler-2");
});
});
});

143
src/hooks/policy.ts Normal file
View File

@ -0,0 +1,143 @@
import type { OpenClawConfig, HookConfig } from "../config/config.js";
import { resolveHookKey } from "./frontmatter.js";
import type { HookEntry, HookSource } from "./types.js";
export type HookEnableStateReason = "disabled in config" | "workspace hook (disabled by default)";
export type HookEnableState = {
enabled: boolean;
reason?: HookEnableStateReason;
};
export type HookSourcePolicy = {
precedence: number;
trustedLocalCode: boolean;
defaultEnableMode: "default-on" | "explicit-opt-in";
canOverride: HookSource[];
canBeOverriddenBy: HookSource[];
};
export type HookResolutionCollision = {
name: string;
kept: HookEntry;
ignored: HookEntry;
};
const HOOK_SOURCE_POLICIES: Record<HookSource, HookSourcePolicy> = {
"openclaw-bundled": {
precedence: 10,
trustedLocalCode: true,
defaultEnableMode: "default-on",
canOverride: ["openclaw-bundled"],
canBeOverriddenBy: ["openclaw-managed", "openclaw-plugin"],
},
"openclaw-plugin": {
precedence: 20,
trustedLocalCode: true,
defaultEnableMode: "default-on",
canOverride: ["openclaw-bundled", "openclaw-plugin"],
canBeOverriddenBy: ["openclaw-managed"],
},
"openclaw-managed": {
precedence: 30,
trustedLocalCode: true,
defaultEnableMode: "default-on",
canOverride: ["openclaw-bundled", "openclaw-managed", "openclaw-plugin"],
canBeOverriddenBy: ["openclaw-managed"],
},
"openclaw-workspace": {
precedence: 40,
trustedLocalCode: true,
defaultEnableMode: "explicit-opt-in",
canOverride: ["openclaw-workspace"],
canBeOverriddenBy: ["openclaw-workspace"],
},
};
export function getHookSourcePolicy(source: HookSource): HookSourcePolicy {
return HOOK_SOURCE_POLICIES[source];
}
export function resolveHookConfig(
config: OpenClawConfig | undefined,
hookKey: string,
): HookConfig | undefined {
const hooks = config?.hooks?.internal?.entries;
if (!hooks || typeof hooks !== "object") {
return undefined;
}
const entry = (hooks as Record<string, HookConfig | undefined>)[hookKey];
if (!entry || typeof entry !== "object") {
return undefined;
}
return entry;
}
export function resolveHookEnableState(params: {
entry: HookEntry;
config?: OpenClawConfig;
hookConfig?: HookConfig;
}): HookEnableState {
const { entry, config } = params;
const hookKey = resolveHookKey(entry.hook.name, entry);
const hookConfig = params.hookConfig ?? resolveHookConfig(config, hookKey);
if (entry.hook.source === "openclaw-plugin") {
return { enabled: true };
}
if (hookConfig?.enabled === false) {
return { enabled: false, reason: "disabled in config" };
}
const sourcePolicy = getHookSourcePolicy(entry.hook.source);
if (sourcePolicy.defaultEnableMode === "explicit-opt-in" && hookConfig?.enabled !== true) {
return { enabled: false, reason: "workspace hook (disabled by default)" };
}
return { enabled: true };
}
function canOverrideHook(candidate: HookEntry, existing: HookEntry): boolean {
const candidatePolicy = getHookSourcePolicy(candidate.hook.source);
const existingPolicy = getHookSourcePolicy(existing.hook.source);
return (
candidatePolicy.canOverride.includes(existing.hook.source) &&
existingPolicy.canBeOverriddenBy.includes(candidate.hook.source)
);
}
export function resolveHookEntries(
entries: HookEntry[],
opts?: {
onCollisionIgnored?: (collision: HookResolutionCollision) => void;
},
): HookEntry[] {
const ordered = entries
.map((entry, index) => ({ entry, index }))
.toSorted((a, b) => {
const precedenceDelta =
getHookSourcePolicy(a.entry.hook.source).precedence -
getHookSourcePolicy(b.entry.hook.source).precedence;
return precedenceDelta !== 0 ? precedenceDelta : a.index - b.index;
});
const merged = new Map<string, HookEntry>();
for (const { entry } of ordered) {
const existing = merged.get(entry.hook.name);
if (!existing) {
merged.set(entry.hook.name, entry);
continue;
}
if (canOverrideHook(entry, existing)) {
merged.set(entry.hook.name, entry);
continue;
}
opts?.onCollisionIgnored?.({
name: entry.hook.name,
kept: existing,
ignored: entry,
});
}
return Array.from(merged.values());
}

View File

@ -14,6 +14,7 @@ import {
resolveOpenClawMetadata,
} from "./frontmatter.js";
import { resolvePluginHookDirs } from "./plugin-hooks.js";
import { resolveHookEntries } from "./policy.js";
import type {
Hook,
HookEligibilityContext,
@ -238,7 +239,7 @@ export function loadHookEntriesFromDir(params: {
});
}
function loadHookEntries(
export function discoverWorkspaceHookEntries(
workspaceDir: string,
opts?: {
config?: OpenClawConfig;
@ -287,32 +288,7 @@ function loadHookEntries(
source: "openclaw-workspace",
});
const merged = new Map<string, HookEntry>();
// Precedence: extra < bundled < plugin < managed < workspace (workspace wins)
for (const entry of extraHooks) {
merged.set(entry.hook.name, entry);
}
for (const entry of bundledHooks) {
merged.set(entry.hook.name, entry);
}
for (const entry of pluginHooks) {
merged.set(entry.hook.name, entry);
}
for (const entry of managedHooks) {
merged.set(entry.hook.name, entry);
}
for (const entry of workspaceHooks) {
const existing = merged.get(entry.hook.name);
if (existing && existing.hook.source !== "openclaw-workspace") {
log.warn(
`Ignoring workspace hook "${entry.hook.name}" because it collides with ${existing.hook.source} hook code`,
);
continue;
}
merged.set(entry.hook.name, entry);
}
return Array.from(merged.values());
return [...extraHooks, ...bundledHooks, ...pluginHooks, ...managedHooks, ...workspaceHooks];
}
export function buildWorkspaceHookSnapshot(
@ -326,7 +302,7 @@ export function buildWorkspaceHookSnapshot(
snapshotVersion?: number;
},
): HookSnapshot {
const hookEntries = opts?.entries ?? loadHookEntries(workspaceDir, opts);
const hookEntries = opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts);
const eligible = filterHookEntries(hookEntries, opts?.config, opts?.eligibility);
return {
@ -345,9 +321,16 @@ export function loadWorkspaceHookEntries(
config?: OpenClawConfig;
managedHooksDir?: string;
bundledHooksDir?: string;
entries?: HookEntry[];
},
): HookEntry[] {
return loadHookEntries(workspaceDir, opts);
return resolveHookEntries(opts?.entries ?? discoverWorkspaceHookEntries(workspaceDir, opts), {
onCollisionIgnored: ({ name, kept, ignored }) => {
log.warn(
`Ignoring ${ignored.hook.source} hook "${name}" because it cannot override ${kept.hook.source} hook code`,
);
},
});
}
function readBoundaryFileUtf8(params: {