Download src/hooks/loader.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 36.8 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/hooks/loader.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/hooks/loader.test.ts
-
curl -L -o loader.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/hooks/loader.test.ts
36.8 kB
| // Hook loader tests cover loading bundled, workspace, and plugin hooks. | |
| import fs from "node:fs/promises"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; | |
| import type { OpenClawConfig } from "../config/config.js"; | |
| import { setLoggerOverride } from "../logging/logger.js"; | |
| import { loggingState } from "../logging/state.js"; | |
| import { createDeferredCore } from "../shared/deferred.js"; | |
| import { drainGlobalSingletonLifecycleState } from "../shared/global-singleton.js"; | |
| import { captureEnv } from "../test-utils/env.js"; | |
| import { | |
| clearInternalHooks, | |
| getRegisteredEventKeys, | |
| triggerInternalHook, | |
| createInternalHookEvent, | |
| registerInternalHook, | |
| setInternalHooksEnabled, | |
| } from "./internal-hooks.js"; | |
| import { prepareInternalHooks } from "./loader.js"; | |
| import type { OpenClawHookMetadata } from "./types.js"; | |
| async function commitPreparedHooks(...args: Parameters<typeof prepareInternalHooks>) { | |
| const prepared = await prepareInternalHooks(...args); | |
| prepared.commit(); | |
| return prepared.loadedCount; | |
| } | |
| describe("loader", () => { | |
| let fixtureRoot = ""; | |
| let caseId = 0; | |
| let tmpDir: string; | |
| let envSnapshot: ReturnType<typeof captureEnv>; | |
| beforeAll(async () => { | |
| fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hooks-loader-")); | |
| }); | |
| beforeEach(async () => { | |
| clearInternalHooks(); | |
| setInternalHooksEnabled(true); | |
| // Create a temp directory for test modules | |
| tmpDir = path.join(fixtureRoot, `case-${caseId++}`); | |
| await fs.mkdir(tmpDir, { recursive: true }); | |
| // Disable bundled hooks during tests by setting env var to non-existent directory | |
| envSnapshot = captureEnv(["OPENCLAW_BUNDLED_HOOKS_DIR"]); | |
| process.env.OPENCLAW_BUNDLED_HOOKS_DIR = "/nonexistent/bundled/hooks"; | |
| setLoggerOverride({ level: "silent", consoleLevel: "error" }); | |
| loggingState.rawConsole = { | |
| log: vi.fn(), | |
| info: vi.fn(), | |
| warn: vi.fn(), | |
| error: vi.fn(), | |
| }; | |
| }); | |
| async function writeDiscoveredHook(params: { | |
| sourceDir?: string; | |
| hookName: string; | |
| handlerCode?: string; | |
| events?: string[]; | |
| exportName?: string; | |
| hookKey?: string; | |
| requires?: OpenClawHookMetadata["requires"]; | |
| }): Promise<string> { | |
| const sourceDir = params.sourceDir ?? path.join(tmpDir, "hooks"); | |
| const hookDir = path.join(sourceDir, params.hookName); | |
| await fs.mkdir(hookDir, { recursive: true }); | |
| const events = params.events ?? ["command:new"]; | |
| const metadata = { | |
| events, | |
| ...(params.exportName ? { export: params.exportName } : {}), | |
| ...(params.hookKey ? { hookKey: params.hookKey } : {}), | |
| ...(params.requires ? { requires: params.requires } : {}), | |
| }; | |
| await fs.writeFile( | |
| path.join(hookDir, "HOOK.md"), | |
| [ | |
| "---", | |
| `name: ${params.hookName}`, | |
| `description: ${params.hookName} test hook`, | |
| `metadata: ${JSON.stringify({ openclaw: metadata })}`, | |
| "---", | |
| "", | |
| `# ${params.hookName}`, | |
| ].join("\n"), | |
| "utf-8", | |
| ); | |
| await fs.writeFile( | |
| path.join(hookDir, "handler.js"), | |
| params.handlerCode ?? | |
| `export default async function(event) { event.messages.push("${params.hookName}"); }\n`, | |
| "utf-8", | |
| ); | |
| return hookDir; | |
| } | |
| function createEnabledHooksConfig(): OpenClawConfig { | |
| return { hooks: { internal: { enabled: true } } }; | |
| } | |
| afterEach(async () => { | |
| clearInternalHooks(); | |
| setInternalHooksEnabled(true); | |
| loggingState.rawConsole = null; | |
| setLoggerOverride(null); | |
| envSnapshot.restore(); | |
| vi.unstubAllGlobals(); | |
| }); | |
| afterAll(async () => { | |
| if (!fixtureRoot) { | |
| return; | |
| } | |
| await fs.rm(fixtureRoot, { recursive: true, force: true }); | |
| }); | |
| describe("prepareInternalHooks", () => { | |
| const expectNoCommandHookRegistration = async (cfg: OpenClawConfig) => { | |
| const count = await commitPreparedHooks(cfg, tmpDir); | |
| expect(count).toBe(0); | |
| expect(getRegisteredEventKeys()).not.toContain("command:new"); | |
| }; | |
| it("should return 0 when hooks are explicitly disabled", async () => { | |
| const count = await commitPreparedHooks( | |
| { hooks: { internal: { enabled: false } } } satisfies OpenClawConfig, | |
| tmpDir, | |
| ); | |
| expect(count).toBe(0); | |
| }); | |
| it("skips hook discovery until internal hooks are configured", async () => { | |
| for (const cfg of [ | |
| {} satisfies OpenClawConfig, | |
| { hooks: {} } satisfies OpenClawConfig, | |
| { hooks: { internal: {} } } satisfies OpenClawConfig, | |
| ]) { | |
| const count = await commitPreparedHooks(cfg, tmpDir); | |
| expect(count).toBe(0); | |
| } | |
| }); | |
| it("loads only explicitly configured discovered hooks", async () => { | |
| const hooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ sourceDir: hooksDir, hookName: "keep-hook" }); | |
| await writeDiscoveredHook({ sourceDir: hooksDir, hookName: "skip-hook" }); | |
| const count = await commitPreparedHooks( | |
| { | |
| hooks: { | |
| internal: { | |
| enabled: true, | |
| entries: { | |
| "keep-hook": { enabled: true }, | |
| }, | |
| }, | |
| }, | |
| } satisfies OpenClawConfig, | |
| tmpDir, | |
| { managedHooksDir: hooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" }, | |
| ); | |
| expect(count).toBe(1); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toEqual(["keep-hook"]); | |
| }); | |
| it("matches configured names against metadata hook keys", async () => { | |
| const hooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ | |
| sourceDir: hooksDir, | |
| hookName: "display-name", | |
| hookKey: "metadata-key", | |
| }); | |
| await writeDiscoveredHook({ sourceDir: hooksDir, hookName: "skip-hook" }); | |
| const count = await commitPreparedHooks( | |
| { | |
| hooks: { | |
| internal: { | |
| enabled: true, | |
| entries: { "metadata-key": { enabled: true } }, | |
| }, | |
| }, | |
| }, | |
| tmpDir, | |
| { managedHooksDir: hooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" }, | |
| ); | |
| expect(count).toBe(1); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toEqual(["display-name"]); | |
| }); | |
| it("registers unknown event keys anyway (advisory warning, not a load failure)", async () => { | |
| const hooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ | |
| sourceDir: hooksDir, | |
| hookName: "typo-hook", | |
| events: ["command:nwe", "command:new"], | |
| }); | |
| const count = await commitPreparedHooks( | |
| { | |
| hooks: { | |
| internal: { | |
| entries: { | |
| "typo-hook": { enabled: true }, | |
| }, | |
| }, | |
| }, | |
| } satisfies OpenClawConfig, | |
| tmpDir, | |
| { managedHooksDir: hooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" }, | |
| ); | |
| // The typo'd key never fires, but validation is advisory: the hook still | |
| // loads and its valid subscriptions keep working. | |
| expect(count).toBe(1); | |
| const keys = getRegisteredEventKeys(); | |
| expect(keys).toContain("command:nwe"); | |
| expect(keys).toContain("command:new"); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toEqual(["typo-hook"]); | |
| }); | |
| it("preserves plugin-registered hooks when workspace hooks reload", async () => { | |
| const pluginHandler = vi.fn(); | |
| registerInternalHook("gateway:startup", pluginHandler); | |
| const count = await commitPreparedHooks(createEnabledHooksConfig(), tmpDir); | |
| expect(count).toBe(0); | |
| expect(getRegisteredEventKeys()).toContain("gateway:startup"); | |
| await triggerInternalHook(createInternalHookEvent("gateway", "startup", "gateway:startup")); | |
| expect(pluginHandler).toHaveBeenCalledTimes(1); | |
| }); | |
| it("replaces prior workspace hook registrations instead of duplicating them", async () => { | |
| const hooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ | |
| sourceDir: hooksDir, | |
| hookName: "reloadable-hook", | |
| }); | |
| const cfg = { | |
| hooks: { | |
| internal: { | |
| enabled: true, | |
| entries: { "reloadable-hook": { enabled: true } }, | |
| }, | |
| }, | |
| } satisfies OpenClawConfig; | |
| const options = { managedHooksDir: hooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" }; | |
| expect(await commitPreparedHooks(cfg, tmpDir, options)).toBe(1); | |
| expect(await commitPreparedHooks(cfg, tmpDir, options)).toBe(1); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect( | |
| event.messages.reduce( | |
| (count, message) => count + (message === "reloadable-hook" ? 1 : 0), | |
| 0, | |
| ), | |
| ).toBe(1); | |
| }); | |
| it.each([ | |
| ["invalid export", 'export default "not a function";'], | |
| ["failed import", 'throw new Error("candidate import failed");'], | |
| ])("retains the committed hook generation after %s", async (_name, handlerCode) => { | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "original" }); | |
| const options = { managedHooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" }; | |
| const config = (names: string[]): OpenClawConfig => ({ | |
| hooks: { | |
| internal: { entries: Object.fromEntries(names.map((name) => [name, { enabled: true }])) }, | |
| }, | |
| }); | |
| await commitPreparedHooks(config(["original"]), tmpDir, options); | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "replacement" }); | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "broken", handlerCode }); | |
| await expect( | |
| commitPreparedHooks(config(["replacement", "broken"]), tmpDir, options), | |
| ).rejects.toThrow(); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toEqual(["original"]); | |
| }); | |
| it("keeps committed hooks serving while replacement imports are pending", async () => { | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| const options = { managedHooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" }; | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "original" }); | |
| await commitPreparedHooks(createEnabledHooksConfig(), tmpDir, options); | |
| const importStarted = createDeferredCore(); | |
| const releaseImport = createDeferredCore(); | |
| vi.stubGlobal("openclawHookImportGate", { | |
| started: importStarted.resolve, | |
| wait: releaseImport.promise, | |
| }); | |
| await writeDiscoveredHook({ | |
| sourceDir: managedHooksDir, | |
| hookName: "replacement", | |
| handlerCode: | |
| 'globalThis.openclawHookImportGate.started(); await globalThis.openclawHookImportGate.wait; export default async function(event) { event.messages.push("replacement"); }', | |
| }); | |
| const loading = prepareInternalHooks( | |
| { | |
| hooks: { internal: { entries: { replacement: { enabled: true } } } }, | |
| }, | |
| tmpDir, | |
| options, | |
| ); | |
| try { | |
| await importStarted.promise; | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toEqual(["original"]); | |
| } finally { | |
| releaseImport.resolve(); | |
| await loading; | |
| } | |
| const prepared = await loading; | |
| const beforeCommit = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(beforeCommit); | |
| expect(beforeCommit.messages).toEqual(["original"]); | |
| prepared.commit(); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toEqual(["replacement"]); | |
| }); | |
| it.each(["missing", "outside hook directory"])( | |
| "retains active handlers when a selected handler becomes %s before sibling reload", | |
| async (failure) => { | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| const options = { managedHooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" }; | |
| const hookDir = await writeDiscoveredHook({ | |
| sourceDir: managedHooksDir, | |
| hookName: "original", | |
| }); | |
| const config: OpenClawConfig = { | |
| hooks: { internal: { entries: { original: { enabled: true } } } }, | |
| }; | |
| await commitPreparedHooks(config, tmpDir, options); | |
| const initial = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(initial); | |
| expect(initial.messages).toEqual(["original"]); | |
| const handlerPath = path.join(hookDir, "handler.js"); | |
| await fs.unlink(handlerPath); | |
| if (failure === "outside hook directory") { | |
| const outside = path.join(tmpDir, "outside.js"); | |
| await fs.writeFile(outside, "export default async function() {}\n"); | |
| await fs.symlink(outside, handlerPath); | |
| } | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "sibling" }); | |
| await expect( | |
| commitPreparedHooks( | |
| { | |
| hooks: { | |
| internal: { | |
| entries: { original: { enabled: true }, sibling: { enabled: true } }, | |
| }, | |
| }, | |
| }, | |
| tmpDir, | |
| options, | |
| ), | |
| ).rejects.toThrow(); | |
| const after = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(after); | |
| expect(after.messages).toEqual(["original"]); | |
| }, | |
| ); | |
| it.each([ | |
| "directory", | |
| "descriptor", | |
| "oversized metadata", | |
| "invalid metadata", | |
| "invalid keyed metadata", | |
| ])( | |
| "retains active handlers when its configured source loses %s before sibling reload", | |
| async (failure) => { | |
| const extraDir = path.join(tmpDir, "extra-hooks"); | |
| const original = await writeDiscoveredHook({ | |
| sourceDir: extraDir, | |
| hookName: "original", | |
| hookKey: "selected-name", | |
| }); | |
| const options = { | |
| managedHooksDir: | |
| failure === "invalid keyed metadata" ? extraDir : path.join(tmpDir, "managed-none"), | |
| }; | |
| const config: OpenClawConfig = { | |
| hooks: { | |
| internal: { | |
| load: { extraDirs: failure === "invalid keyed metadata" ? [] : [extraDir] }, | |
| entries: { "selected-name": { enabled: true } }, | |
| }, | |
| }, | |
| }; | |
| await commitPreparedHooks(config, tmpDir, options); | |
| const initial = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(initial); | |
| expect(initial.messages).toEqual(["original"]); | |
| if (failure === "directory") { | |
| await fs.rm(original, { recursive: true }); | |
| } else if (failure === "descriptor") { | |
| await fs.unlink(path.join(original, "HOOK.md")); | |
| } else { | |
| await fs.writeFile( | |
| path.join(original, "HOOK.md"), | |
| failure === "oversized metadata" | |
| ? "x".repeat(1024 * 1024 + 1) | |
| : "---\nname: original\nmetadata: {invalid\n---\n", | |
| ); | |
| } | |
| await writeDiscoveredHook({ sourceDir: extraDir, hookName: "sibling" }); | |
| await expect(commitPreparedHooks(config, tmpDir, options)).rejects.toThrow(); | |
| const after = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(after); | |
| expect(after.messages).toEqual(["original"]); | |
| }, | |
| ); | |
| it.each(["disabled", "ineligible", "unselected", "collision loser"])( | |
| "ignores a missing handler for a %s during atomic reload", | |
| async (selection) => { | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| const options = { managedHooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" }; | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "valid" }); | |
| const broken = await writeDiscoveredHook({ | |
| sourceDir: selection === "collision loser" ? undefined : managedHooksDir, | |
| hookName: selection === "collision loser" ? "valid" : "broken", | |
| requires: selection === "ineligible" ? { config: ["browser.enabled"] } : undefined, | |
| }); | |
| await fs.unlink(path.join(broken, "handler.js")); | |
| await expect( | |
| commitPreparedHooks( | |
| { | |
| browser: { enabled: false }, | |
| hooks: { | |
| internal: { | |
| entries: { | |
| valid: { enabled: true }, | |
| ...(selection === "disabled" ? { broken: { enabled: false } } : {}), | |
| ...(selection === "ineligible" ? { broken: { enabled: true } } : {}), | |
| }, | |
| }, | |
| }, | |
| }, | |
| tmpDir, | |
| options, | |
| ), | |
| ).resolves.toBe(1); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toEqual(["valid"]); | |
| }, | |
| ); | |
| it.each( | |
| ["ineligible", "disabled", "unselected"].flatMap((selection) => | |
| ["handler", "directory"].map((failure) => ({ selection, failure })), | |
| ), | |
| )( | |
| "does not activate a shadowed hook when its $selection override loses its $failure", | |
| async ({ selection, failure }) => { | |
| const bundledHooksDir = path.join(tmpDir, "bundled-hooks"); | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| const options = { bundledHooksDir, managedHooksDir }; | |
| await writeDiscoveredHook({ | |
| sourceDir: bundledHooksDir, | |
| hookName: "overridden", | |
| hookKey: "bundled-selection", | |
| handlerCode: 'export default async function(event) { event.messages.push("bundled"); }', | |
| }); | |
| const overriding = await writeDiscoveredHook({ | |
| sourceDir: managedHooksDir, | |
| hookName: "overridden", | |
| hookKey: "managed-selection", | |
| requires: selection === "ineligible" ? { config: ["browser.enabled"] } : undefined, | |
| }); | |
| const entries = { | |
| "bundled-selection": { enabled: true }, | |
| ...(selection !== "unselected" | |
| ? { "managed-selection": { enabled: selection !== "disabled" } } | |
| : {}), | |
| }; | |
| const config: OpenClawConfig = { | |
| browser: { enabled: false }, | |
| hooks: { internal: { entries } }, | |
| }; | |
| await expect(commitPreparedHooks(config, tmpDir, options)).resolves.toBe(0); | |
| const initial = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(initial); | |
| expect(initial.messages).toEqual([]); | |
| if (failure === "directory") { | |
| await fs.rm(overriding, { recursive: true }); | |
| } else { | |
| await fs.unlink(path.join(overriding, "handler.js")); | |
| } | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "sibling" }); | |
| await commitPreparedHooks( | |
| { | |
| ...config, | |
| hooks: { internal: { entries: { ...entries, sibling: { enabled: true } } } }, | |
| }, | |
| tmpDir, | |
| options, | |
| ); | |
| const after = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(after); | |
| expect(after.messages).toEqual(["sibling"]); | |
| }, | |
| ); | |
| it("allows a valid higher-precedence source to replace a lost selected source", async () => { | |
| const bundledHooksDir = path.join(tmpDir, "bundled-hooks"); | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| const original = await writeDiscoveredHook({ | |
| sourceDir: bundledHooksDir, | |
| hookName: "original", | |
| }); | |
| const options = { bundledHooksDir, managedHooksDir }; | |
| const config = createEnabledHooksConfig(); | |
| await commitPreparedHooks(config, tmpDir, options); | |
| await fs.rm(original, { recursive: true }); | |
| await writeDiscoveredHook({ | |
| sourceDir: managedHooksDir, | |
| hookName: "original", | |
| handlerCode: 'export default async function(event) { event.messages.push("replacement"); }', | |
| }); | |
| await commitPreparedHooks(config, tmpDir, options); | |
| const after = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(after); | |
| expect(after.messages).toEqual(["replacement"]); | |
| }); | |
| it.each(["disable", "ineligible", "name removal", "root removal", "install removal"])( | |
| "accepts intentional %s after a winning source disappears", | |
| async (removal) => { | |
| const bundledHooksDir = path.join(tmpDir, "bundled-hooks"); | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| const extraDir = path.join(tmpDir, "extra-hooks"); | |
| const options = { bundledHooksDir, managedHooksDir }; | |
| await writeDiscoveredHook({ | |
| sourceDir: bundledHooksDir, | |
| hookName: "original", | |
| hookKey: "lower-name", | |
| handlerCode: 'export default async function(event) { event.messages.push("lower"); }', | |
| }); | |
| const original = await writeDiscoveredHook({ | |
| sourceDir: extraDir, | |
| hookName: "original", | |
| hookKey: "selected-name", | |
| requires: { config: ["browser.enabled"] }, | |
| }); | |
| const config: OpenClawConfig = { | |
| browser: { enabled: true }, | |
| hooks: { | |
| internal: { | |
| load: { extraDirs: [extraDir] }, | |
| entries: { | |
| "lower-name": { enabled: true }, | |
| ...(removal === "install removal" ? {} : { "selected-name": { enabled: true } }), | |
| }, | |
| }, | |
| }, | |
| }; | |
| const state = await import("../state/config-machine-state.js"); | |
| const installed = vi.spyOn(state, "readConfigMachineState"); | |
| installed.mockReturnValue( | |
| removal === "install removal" | |
| ? { pack: { source: "path", hooks: ["selected-name"] } } | |
| : undefined, | |
| ); | |
| try { | |
| await commitPreparedHooks(config, tmpDir, options); | |
| await fs.rm(original, { recursive: true }); | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "sibling" }); | |
| const entries: Record<string, { enabled?: boolean }> = { | |
| ...config.hooks?.internal?.entries, | |
| sibling: { enabled: true }, | |
| }; | |
| if (removal === "disable") { | |
| entries["selected-name"] = { enabled: false }; | |
| } | |
| if (removal === "name removal") { | |
| delete entries["selected-name"]; | |
| } | |
| if (removal === "install removal") { | |
| installed.mockReturnValue(undefined); | |
| } | |
| const next: OpenClawConfig = { | |
| ...config, | |
| browser: { enabled: removal !== "ineligible" }, | |
| hooks: { | |
| internal: { | |
| entries, | |
| load: { extraDirs: removal === "root removal" ? [] : [extraDir] }, | |
| }, | |
| }, | |
| }; | |
| await commitPreparedHooks(next, tmpDir, options); | |
| const after = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(after); | |
| expect(after.messages).toEqual( | |
| removal === "disable" || removal === "ineligible" ? ["sibling"] : ["lower", "sibling"], | |
| ); | |
| } finally { | |
| installed.mockRestore(); | |
| } | |
| }, | |
| ); | |
| it("drops a lost workspace source when the selected workspace changes", async () => { | |
| const original = await writeDiscoveredHook({ hookName: "original" }); | |
| const config: OpenClawConfig = { | |
| hooks: { internal: { entries: { original: { enabled: true } } } }, | |
| }; | |
| await commitPreparedHooks(config, tmpDir); | |
| await fs.rm(original, { recursive: true }); | |
| const nextWorkspace = path.join(tmpDir, "new-workspace"); | |
| await writeDiscoveredHook({ | |
| sourceDir: path.join(nextWorkspace, "hooks"), | |
| hookName: "original", | |
| handlerCode: | |
| 'export default async function(event) { event.messages.push("new-workspace"); }', | |
| }); | |
| await commitPreparedHooks(config, nextWorkspace); | |
| const after = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(after); | |
| expect(after.messages).toEqual(["new-workspace"]); | |
| }); | |
| it("keeps best-effort startup's intact lower-priority hook after discovery rejection", async () => { | |
| const bundledHooksDir = path.join(tmpDir, "bundled-hooks"); | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ sourceDir: bundledHooksDir, hookName: "original" }); | |
| const broken = await writeDiscoveredHook({ | |
| sourceDir: managedHooksDir, | |
| hookName: "original", | |
| }); | |
| await fs.unlink(path.join(broken, "handler.js")); | |
| await expect( | |
| commitPreparedHooks(createEnabledHooksConfig(), tmpDir, { | |
| managedHooksDir, | |
| bundledHooksDir, | |
| failureMode: "best-effort", | |
| }), | |
| ).resolves.toBe(1); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toEqual(["original"]); | |
| }); | |
| it("commits disable and re-enable for registered internal hook listeners", async () => { | |
| const handler = vi.fn(); | |
| registerInternalHook("command:new", handler); | |
| const disabled = await prepareInternalHooks( | |
| { hooks: { internal: { enabled: false } } }, | |
| tmpDir, | |
| ); | |
| await triggerInternalHook(createInternalHookEvent("command", "new", "test-session")); | |
| expect(handler).toHaveBeenCalledTimes(1); | |
| disabled.commit(); | |
| await triggerInternalHook(createInternalHookEvent("command", "new", "test-session")); | |
| expect(handler).toHaveBeenCalledTimes(1); | |
| const enabled = await prepareInternalHooks(createEnabledHooksConfig(), tmpDir); | |
| await triggerInternalHook(createInternalHookEvent("command", "new", "test-session")); | |
| expect(handler).toHaveBeenCalledTimes(1); | |
| enabled.commit(); | |
| await triggerInternalHook(createInternalHookEvent("command", "new", "test-session")); | |
| expect(handler).toHaveBeenCalledTimes(2); | |
| }); | |
| it.each(["before preparation", "after preparation", "after shutdown"])( | |
| "keeps deferred startup from overwriting ownership transferred %s", | |
| async (transfer) => { | |
| await drainGlobalSingletonLifecycleState("restart"); | |
| const handler = vi.fn(); | |
| registerInternalHook("command:new", handler); | |
| const disable = () => | |
| commitPreparedHooks({ hooks: { internal: { enabled: false } } }, tmpDir); | |
| if (transfer === "before preparation") { | |
| await disable(); | |
| } | |
| const startup = await prepareInternalHooks(createEnabledHooksConfig(), tmpDir); | |
| if (transfer === "after preparation") { | |
| await disable(); | |
| } else if (transfer === "after shutdown") { | |
| await drainGlobalSingletonLifecycleState("restart"); | |
| await disable(); | |
| } | |
| expect(startup.commit({ initial: true })).toBe(false); | |
| await triggerInternalHook(createInternalHookEvent("command", "new", "test-session")); | |
| expect(handler).not.toHaveBeenCalled(); | |
| }, | |
| ); | |
| it.each(["replacement", "disable"])( | |
| "finishes an admitted event's original handlers across %s", | |
| async (change) => { | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| const options = { managedHooksDir, bundledHooksDir: "/nonexistent/bundled/hooks" }; | |
| const started = createDeferredCore(); | |
| const release = createDeferredCore(); | |
| registerInternalHook("command", async () => { | |
| started.resolve(); | |
| await release.promise; | |
| }); | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "original" }); | |
| await commitPreparedHooks(createEnabledHooksConfig(), tmpDir, options); | |
| const originalEvent = createInternalHookEvent("command", "new", "test-session"); | |
| const dispatch = triggerInternalHook(originalEvent); | |
| try { | |
| await started.promise; | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "replacement" }); | |
| await commitPreparedHooks( | |
| { | |
| hooks: { | |
| internal: { | |
| enabled: change !== "disable", | |
| entries: { replacement: { enabled: true } }, | |
| }, | |
| }, | |
| }, | |
| tmpDir, | |
| options, | |
| ); | |
| } finally { | |
| release.resolve(); | |
| await dispatch; | |
| } | |
| expect(originalEvent.messages).toEqual(["original"]); | |
| const nextEvent = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(nextEvent); | |
| expect(nextEvent.messages).toEqual(change === "disable" ? [] : ["replacement"]); | |
| }, | |
| ); | |
| it("keeps configured hooks across plugin cleanup and retires them on Gateway restart", async () => { | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ sourceDir: managedHooksDir, hookName: "managed" }); | |
| await commitPreparedHooks(createEnabledHooksConfig(), tmpDir, { managedHooksDir }); | |
| const unrelated = vi.fn(); | |
| registerInternalHook("command:new", unrelated); | |
| await drainGlobalSingletonLifecycleState("plugin-registry"); | |
| const afterPluginCleanup = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(afterPluginCleanup); | |
| expect(afterPluginCleanup.messages).toEqual(["managed"]); | |
| expect(unrelated).toHaveBeenCalledTimes(1); | |
| await drainGlobalSingletonLifecycleState("restart"); | |
| const afterRestart = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(afterRestart); | |
| expect(afterRestart.messages).toEqual([]); | |
| expect(unrelated).toHaveBeenCalledTimes(2); | |
| }); | |
| it("should support named exports", async () => { | |
| const hooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ | |
| sourceDir: hooksDir, | |
| hookName: "named-export", | |
| exportName: "myHandler", | |
| handlerCode: "export const myHandler = async function() {};\n", | |
| }); | |
| const cfg = { | |
| hooks: { | |
| internal: { | |
| enabled: true, | |
| entries: { "named-export": { enabled: true } }, | |
| }, | |
| }, | |
| } satisfies OpenClawConfig; | |
| const count = await commitPreparedHooks(cfg, tmpDir, { | |
| managedHooksDir: hooksDir, | |
| bundledHooksDir: "/nonexistent/bundled/hooks", | |
| }); | |
| expect(count).toBe(1); | |
| }); | |
| it("skips invalid handlers while preserving valid siblings during best-effort startup", async () => { | |
| const hooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ | |
| sourceDir: hooksDir, | |
| hookName: "bad-export", | |
| handlerCode: 'export default "not a function";\n', | |
| }); | |
| await writeDiscoveredHook({ sourceDir: hooksDir, hookName: "valid" }); | |
| const count = await commitPreparedHooks( | |
| { | |
| hooks: { | |
| internal: { | |
| enabled: true, | |
| entries: { "bad-export": { enabled: true }, valid: { enabled: true } }, | |
| }, | |
| }, | |
| }, | |
| tmpDir, | |
| { | |
| managedHooksDir: hooksDir, | |
| bundledHooksDir: "/nonexistent/bundled/hooks", | |
| failureMode: "best-effort", | |
| }, | |
| ); | |
| expect(count).toBe(1); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toEqual(["valid"]); | |
| }); | |
| it("keeps workspace hooks disabled by default until explicitly enabled", async () => { | |
| await writeDiscoveredHook({ hookName: "workspace-hook" }); | |
| const disabledCount = await commitPreparedHooks(createEnabledHooksConfig(), tmpDir); | |
| expect(disabledCount).toBe(0); | |
| expect(getRegisteredEventKeys()).not.toContain("command:new"); | |
| const enabledCount = await commitPreparedHooks( | |
| { | |
| hooks: { | |
| internal: { | |
| enabled: true, | |
| entries: { | |
| "workspace-hook": { | |
| enabled: true, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| tmpDir, | |
| ); | |
| expect(enabledCount).toBe(1); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toContain("workspace-hook"); | |
| }); | |
| it("rejects directory hook handlers that escape hook dir via symlink", async () => { | |
| const outsideHandlerPath = path.join(fixtureRoot, `outside-handler-${caseId}.js`); | |
| await fs.writeFile(outsideHandlerPath, "export default async function() {}", "utf-8"); | |
| const hookDir = path.join(tmpDir, "hooks", "symlink-hook"); | |
| await fs.mkdir(hookDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(hookDir, "HOOK.md"), | |
| [ | |
| "---", | |
| "name: symlink-hook", | |
| "description: symlink test", | |
| 'metadata: {"openclaw":{"events":["command:new"]}}', | |
| "---", | |
| "", | |
| "# Symlink Hook", | |
| ].join("\n"), | |
| "utf-8", | |
| ); | |
| try { | |
| await fs.symlink(outsideHandlerPath, path.join(hookDir, "handler.js")); | |
| } catch { | |
| return; | |
| } | |
| await expectNoCommandHookRegistration(createEnabledHooksConfig()); | |
| }); | |
| it("rejects directory hook handlers that escape hook dir via hardlink", async () => { | |
| if (process.platform === "win32") { | |
| return; | |
| } | |
| const outsideHandlerPath = path.join(fixtureRoot, `outside-handler-hardlink-${caseId}.js`); | |
| await fs.writeFile(outsideHandlerPath, "export default async function() {}", "utf-8"); | |
| const hookDir = path.join(tmpDir, "hooks", "hardlink-hook"); | |
| await fs.mkdir(hookDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(hookDir, "HOOK.md"), | |
| [ | |
| "---", | |
| "name: hardlink-hook", | |
| "description: hardlink test", | |
| 'metadata: {"openclaw":{"events":["command:new"]}}', | |
| "---", | |
| "", | |
| "# Hardlink Hook", | |
| ].join("\n"), | |
| "utf-8", | |
| ); | |
| try { | |
| await fs.link(outsideHandlerPath, path.join(hookDir, "handler.js")); | |
| } catch (err) { | |
| if ((err as NodeJS.ErrnoException).code === "EXDEV") { | |
| return; | |
| } | |
| throw err; | |
| } | |
| await expectNoCommandHookRegistration(createEnabledHooksConfig()); | |
| }); | |
| it("keeps managed hooks active when a workspace hook reuses the same name", async () => { | |
| const managedHooksDir = path.join(tmpDir, "managed-hooks"); | |
| await writeDiscoveredHook({ | |
| sourceDir: managedHooksDir, | |
| hookName: "session-memory", | |
| handlerCode: 'export default async function(event) { event.messages.push("managed"); }\n', | |
| }); | |
| await writeDiscoveredHook({ | |
| hookName: "session-memory", | |
| handlerCode: | |
| 'export default async function(event) { event.messages.push("workspace-override"); }\n', | |
| }); | |
| const count = await commitPreparedHooks( | |
| { | |
| hooks: { | |
| internal: { | |
| enabled: true, | |
| entries: { | |
| "session-memory": { | |
| enabled: true, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| tmpDir, | |
| { managedHooksDir }, | |
| ); | |
| expect(count).toBe(1); | |
| const event = createInternalHookEvent("command", "new", "test-session"); | |
| await triggerInternalHook(event); | |
| expect(event.messages).toContain("managed"); | |
| expect(event.messages).not.toContain("workspace-override"); | |
| }); | |
| }); | |
| }); | |