import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { loadHookEntriesFromDir, loadWorkspaceHookEntries } from "./workspace.js"; function writeHookPackageManifest(pkgDir: string, hooks: string[]): void { fs.writeFileSync( path.join(pkgDir, "package.json"), JSON.stringify( { name: "pkg", [MANIFEST_KEY]: { hooks, }, }, null, 2, ), ); } function setupHardlinkHookWorkspace(hookName: string): { hooksRoot: string; hookDir: string; outsideDir: string; } { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-hardlink-")); const hooksRoot = path.join(root, "hooks"); fs.mkdirSync(hooksRoot, { recursive: true }); const hookDir = path.join(hooksRoot, hookName); const outsideDir = path.join(root, "outside"); fs.mkdirSync(hookDir, { recursive: true }); fs.mkdirSync(outsideDir, { recursive: true }); return { hooksRoot, hookDir, outsideDir }; } function tryCreateHardlinkOrSkip(createLink: () => void): boolean { try { createLink(); return true; } catch (err) { if ((err as NodeJS.ErrnoException).code === "EXDEV") { return false; } throw err; } } describe("hooks workspace", () => { it("ignores package.json hook paths that traverse outside package directory", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-")); const hooksRoot = path.join(root, "hooks"); fs.mkdirSync(hooksRoot, { recursive: true }); const pkgDir = path.join(hooksRoot, "pkg"); fs.mkdirSync(pkgDir, { recursive: true }); const outsideHookDir = path.join(root, "outside"); fs.mkdirSync(outsideHookDir, { recursive: true }); fs.writeFileSync(path.join(outsideHookDir, "HOOK.md"), "---\nname: outside\n---\n"); fs.writeFileSync(path.join(outsideHookDir, "handler.js"), "export default async () => {};\n"); writeHookPackageManifest(pkgDir, ["../outside"]); const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); expect(entries.some((e) => e.hook.name === "outside")).toBe(false); }); it("accepts package.json hook paths within package directory", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-ok-")); const hooksRoot = path.join(root, "hooks"); fs.mkdirSync(hooksRoot, { recursive: true }); const pkgDir = path.join(hooksRoot, "pkg"); const nested = path.join(pkgDir, "nested"); fs.mkdirSync(nested, { recursive: true }); fs.writeFileSync(path.join(nested, "HOOK.md"), "---\nname: nested\n---\n"); fs.writeFileSync(path.join(nested, "handler.js"), "export default async () => {};\n"); writeHookPackageManifest(pkgDir, ["./nested"]); const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); expect(entries.some((e) => e.hook.name === "nested")).toBe(true); }); it("ignores package.json hook paths that escape via symlink", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-link-")); const hooksRoot = path.join(root, "hooks"); fs.mkdirSync(hooksRoot, { recursive: true }); const pkgDir = path.join(hooksRoot, "pkg"); const outsideDir = path.join(root, "outside"); const linkedDir = path.join(pkgDir, "linked"); fs.mkdirSync(pkgDir, { recursive: true }); fs.mkdirSync(outsideDir, { recursive: true }); fs.writeFileSync(path.join(outsideDir, "HOOK.md"), "---\nname: outside\n---\n"); fs.writeFileSync(path.join(outsideDir, "handler.js"), "export default async () => {};\n"); try { fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); } catch { return; } writeHookPackageManifest(pkgDir, ["./linked"]); const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); expect(entries.some((e) => e.hook.name === "outside")).toBe(false); }); it("ignores hooks with hardlinked HOOK.md aliases", () => { if (process.platform === "win32") { return; } const { hooksRoot, hookDir, outsideDir } = setupHardlinkHookWorkspace("hardlink-hook"); fs.writeFileSync(path.join(hookDir, "handler.js"), "export default async () => {};\n"); const outsideHookMd = path.join(outsideDir, "HOOK.md"); const linkedHookMd = path.join(hookDir, "HOOK.md"); fs.writeFileSync(linkedHookMd, "---\nname: hardlink-hook\n---\n"); fs.rmSync(linkedHookMd); fs.writeFileSync(outsideHookMd, "---\nname: outside\n---\n"); if (!tryCreateHardlinkOrSkip(() => fs.linkSync(outsideHookMd, linkedHookMd))) { return; } const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); expect(entries.some((e) => e.hook.name === "hardlink-hook")).toBe(false); expect(entries.some((e) => e.hook.name === "outside")).toBe(false); }); it("ignores hooks with hardlinked handler aliases", () => { if (process.platform === "win32") { return; } const { hooksRoot, hookDir, outsideDir } = setupHardlinkHookWorkspace("hardlink-handler-hook"); fs.writeFileSync(path.join(hookDir, "HOOK.md"), "---\nname: hardlink-handler-hook\n---\n"); const outsideHandler = path.join(outsideDir, "handler.js"); const linkedHandler = path.join(hookDir, "handler.js"); fs.writeFileSync(outsideHandler, "export default async () => {};\n"); if (!tryCreateHardlinkOrSkip(() => fs.linkSync(outsideHandler, linkedHandler))) { return; } const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); expect(entries.some((e) => e.hook.name === "hardlink-handler-hook")).toBe(false); }); it("does not let workspace hooks override managed hooks with the same name", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-collision-")); const workspaceDir = path.join(root, "workspace"); const managedHooksDir = path.join(root, "managed-hooks"); const workspaceHookDir = path.join(workspaceDir, "hooks", "session-memory"); const managedHookDir = path.join(managedHooksDir, "session-memory"); fs.mkdirSync(workspaceHookDir, { recursive: true }); fs.mkdirSync(managedHookDir, { recursive: true }); for (const dir of [workspaceHookDir, managedHookDir]) { fs.writeFileSync( path.join(dir, "HOOK.md"), [ "---", "name: session-memory", 'metadata: {"openclaw":{"events":["command:new"]}}', "---", ].join("\n"), ); fs.writeFileSync(path.join(dir, "handler.js"), "export default async () => {};\n"); } const entries = loadWorkspaceHookEntries(workspaceDir, { managedHooksDir, bundledHooksDir: path.join(root, "bundled-none"), }); expect(entries).toHaveLength(1); expect(entries[0]?.hook.source).toBe("openclaw-managed"); }); it("treats configured extraDirs as managed hook sources", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-extra-")); const workspaceDir = path.join(root, "workspace"); const extraHookDir = path.join(root, "shared-hooks", "shared-hook"); fs.mkdirSync(extraHookDir, { recursive: true }); fs.writeFileSync( path.join(extraHookDir, "HOOK.md"), ["---", "name: shared-hook", 'metadata: {"openclaw":{"events":["command:new"]}}', "---"].join( "\n", ), ); fs.writeFileSync(path.join(extraHookDir, "handler.js"), "export default async () => {};\n"); const entries = loadWorkspaceHookEntries(workspaceDir, { bundledHooksDir: path.join(root, "bundled-none"), config: { hooks: { internal: { enabled: true, load: { extraDirs: [path.join(root, "shared-hooks")], }, }, }, }, }); expect(entries).toHaveLength(1); expect(entries[0]?.hook.name).toBe("shared-hook"); expect(entries[0]?.hook.source).toBe("openclaw-managed"); }); });