/** * @file Tests server/lib/antigravity-hooks.js — AgentHub's named hook in * `~/.gemini/config/hooks.json`: installed as `agenthub` with only * PreInvocation/PostToolUse/Stop (never PreToolUse, whose `{}` answer makes * agy deny the tool), every other named hook kept, remove taking out only * ours, a non-JSON file refused, a hook switched off reported, the forwarder * answering `{}` whatever the dashboard does, and refreshed only when it is * ours. The Gemini dir is a temp dir. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const fs = require("node:fs"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-hooks-")); process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini"); process.env.CLAUDE_HOME = path.join(ROOT, "claude"); const { describe, it, beforeEach, after } = require("node:test"); const assert = require("node:assert/strict"); const hooks = require("../lib/antigravity-hooks"); after(() => fs.rmSync(ROOT, { recursive: true, force: true })); const CONFIG = path.join(process.env.AGENTHUB_GEMINI_DIR, "config"); const FILE = path.join(CONFIG, "hooks.json"); const SCRIPT = path.join(CONFIG, "agenthub-hook.sh"); const read = () => JSON.parse(fs.readFileSync(FILE, "utf8")); const theirs = { enabled: true, PreToolUse: [{ matcher: "run_command", hooks: [{ type: "command", command: "guard.sh" }] }], }; beforeEach(() => { fs.rmSync(process.env.AGENTHUB_GEMINI_DIR, { recursive: true, force: true }); fs.mkdirSync(CONFIG, { recursive: true }); }); describe("antigravity hooks", () => { it("installs one named hook with the three observational events, never PreToolUse", () => { const st = hooks.install(); assert.equal(st.installed, true); assert.equal(st.enabled, true); const ours = read().agenthub; assert.deepEqual(Object.keys(ours).sort(), ["PostToolUse", "PreInvocation", "Stop", "enabled"]); assert.equal(ours.PreToolUse, undefined); const cmd = (e) => `'${SCRIPT}' ${e}`; assert.deepEqual(ours.PreInvocation, [ { type: "command", command: cmd("PreInvocation"), timeout: 5 }, ]); assert.deepEqual(ours.PostToolUse, [ { matcher: ".*", hooks: [{ type: "command", command: cmd("PostToolUse"), timeout: 5 }] }, ]); assert.equal(fs.statSync(SCRIPT).mode & 0o111, 0o111); }); it("answers {} on stdout and nothing else, with no dashboard to reach", () => { hooks.install(); const out = execFileSync("sh", [SCRIPT, "Stop"], { input: JSON.stringify({ conversationId: "x" }), env: { PATH: process.env.PATH, CLAUDE_DASHBOARD_PORT: "1" }, timeout: 10_000, }).toString(); assert.equal(out, "{}\n"); }); it("keeps every other named hook, and remove takes out only ours", () => { fs.writeFileSync(FILE, JSON.stringify({ "my-guard": theirs })); hooks.install(); hooks.install(); assert.deepEqual(Object.keys(read()).sort(), ["agenthub", "my-guard"]); assert.deepEqual(read()["my-guard"], theirs); const st = hooks.remove(); assert.equal(st.installed, false); assert.deepEqual(read(), { "my-guard": theirs }); assert.equal(fs.existsSync(SCRIPT), false); hooks.install(); hooks.remove(); assert.equal(fs.existsSync(FILE), true); // theirs is still in it fs.rmSync(FILE); hooks.install(); hooks.remove(); assert.equal(fs.existsSync(FILE), false); }); it("refuses a hooks.json that is not a JSON object, and changes nothing", () => { fs.writeFileSync(FILE, "[]"); assert.throws(() => hooks.install(), { code: "EBADJSON" }); assert.equal(fs.readFileSync(FILE, "utf8"), "[]"); assert.equal(hooks.status().readable, false); }); it("reports a hook the user switched off", () => { hooks.install(); const file = read(); file.agenthub.enabled = false; fs.writeFileSync(FILE, JSON.stringify(file)); const st = hooks.status(); assert.equal(st.installed, true); assert.equal(st.enabled, false); }); it("refreshes a stale forwarder, and leaves a file that is not ours alone", () => { assert.equal(hooks.refreshScript(), "absent"); hooks.install(); const stale = fs.readFileSync(SCRIPT, "utf8").replace(/'[^']*hook-handler\.js'/, "'/old/h.js'"); fs.writeFileSync(SCRIPT, stale); assert.equal(hooks.refreshScript(), "written"); assert.equal(hooks.refreshScript(), "exists"); fs.writeFileSync(SCRIPT, "#!/bin/sh\necho mine\n"); assert.equal(hooks.refreshScript(), "foreign"); }); it("never overwrites or removes a script that is not ours, and keeps hooks.json's mode", () => { fs.writeFileSync(SCRIPT, "#!/bin/sh\necho mine\n"); assert.throws(() => hooks.install(), { code: "EFOREIGN" }); assert.equal(fs.existsSync(FILE), false); hooks.remove(); assert.equal(fs.readFileSync(SCRIPT, "utf8"), "#!/bin/sh\necho mine\n"); fs.rmSync(SCRIPT); fs.writeFileSync(FILE, JSON.stringify({ theirs }), { mode: 0o600 }); hooks.install(); assert.equal(fs.statSync(FILE).mode & 0o777, 0o600); hooks.remove(); assert.equal(fs.statSync(FILE).mode & 0o777, 0o600); }); it("treats a directory at the forwarder's path as not ours, and still removes the hook", () => { hooks.install(); fs.rmSync(SCRIPT); fs.mkdirSync(SCRIPT); assert.throws(() => hooks.install(), { code: "EFOREIGN" }); const st = hooks.remove(); assert.equal(st.events.Stop.installed, false); assert.ok(fs.statSync(SCRIPT).isDirectory()); }); });