mirror of https://github.com/openclaw/openclaw.git
refactor(hooks): unify hook policy resolution
This commit is contained in:
parent
c96c319db3
commit
5cb2f45585
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(", ")}`);
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue