/** * @file Tests server/lib/codex-hooks.js — installing AgentHub's hooks into * Codex's `$CODEX_HOME/hooks.json`: a user's own hooks kept, a reinstall * replacing only ours, remove leaving theirs, a non-JSON file refused, trust * read from `config.toml` (also under a symlinked home), a user's hook in our * group or with a similar name never removed with ours, and the forwarding * script refreshed — only when it is ours — without its command (which Codex's * trust is keyed on) changing. CODEX_HOME 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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-hooks-")); process.env.CODEX_HOME = path.join(ROOT, "codex"); 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/codex-hooks"); after(() => fs.rmSync(ROOT, { recursive: true, force: true })); const HOME = process.env.CODEX_HOME; const FILE = path.join(HOME, "hooks.json"); const read = () => JSON.parse(fs.readFileSync(FILE, "utf8")); const theirs = { hooks: [{ type: "command", command: "my-audit.sh", timeout: 10 }] }; beforeEach(() => { fs.rmSync(HOME, { recursive: true, force: true }); fs.mkdirSync(HOME, { recursive: true }); }); describe("codex hooks", () => { it("installs one group per event, all through the stable script", () => { const st = hooks.install(); assert.equal(st.installed, true); const file = read(); assert.deepEqual(Object.keys(file.hooks).sort(), [...hooks.HOOK_EVENTS].sort()); for (const e of hooks.HOOK_EVENTS) { assert.deepEqual(file.hooks[e], [ { hooks: [ { type: "command", command: `'${path.join(HOME, "agenthub-hook.sh")}' ${e}`, timeout: 3, }, ], }, ]); } const script = fs.readFileSync(path.join(HOME, "agenthub-hook.sh"), "utf8"); assert.match(script, /^#!\/bin\/sh\n/); assert.ok(script.includes(path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js"))); assert.ok(script.includes(`CLAUDE_HOME='${process.env.CLAUDE_HOME}'`)); assert.ok(script.includes('"$1" codex')); assert.equal(fs.statSync(path.join(HOME, "agenthub-hook.sh")).mode & 0o111, 0o111); }); it("keeps the user's own hooks, and a reinstall replaces only ours", () => { fs.writeFileSync( FILE, JSON.stringify({ description: "mine", hooks: { PreToolUse: [theirs], Notify: [theirs] } }) ); hooks.install(); hooks.install(); const file = read(); assert.equal(file.description, "mine"); assert.deepEqual(file.hooks.Notify, [theirs]); assert.equal(file.hooks.PreToolUse.length, 2); assert.deepEqual(file.hooks.PreToolUse[0], theirs); assert.equal(file.hooks.Stop.length, 1); }); it("remove takes out only ours, and deletes a file that held nothing else", () => { fs.writeFileSync(FILE, JSON.stringify({ hooks: { PreToolUse: [theirs] } })); hooks.install(); let st = hooks.remove(); assert.equal(st.installed, false); assert.deepEqual(read(), { hooks: { PreToolUse: [theirs] } }); assert.equal(fs.existsSync(path.join(HOME, "agenthub-hook.sh")), false); fs.rmSync(FILE); hooks.install(); st = hooks.remove(); assert.equal(fs.existsSync(FILE), false); assert.equal(st.installed, false); }); it("refuses a hooks.json that is not a JSON object, and changes nothing", () => { fs.writeFileSync(FILE, "{ not json"); assert.throws(() => hooks.install(), { code: "EBADJSON" }); assert.equal(fs.readFileSync(FILE, "utf8"), "{ not json"); assert.equal(hooks.status().readable, false); }); it("reports trust from Codex's own hooks.state entries", () => { fs.writeFileSync(FILE, JSON.stringify({ hooks: { PreToolUse: [theirs] } })); hooks.install(); let st = hooks.status(); assert.equal(st.events.PreToolUse.trusted, false); // Ours is the second PreToolUse group (index 1); Stop's is the first. fs.writeFileSync( path.join(HOME, "config.toml"), [ 'model = "x"', "", `[hooks.state."${FILE}:pre_tool_use:1:0"]`, 'trusted_hash = "sha256:aa"', `[hooks.state."${FILE}:stop:0:0"]`, 'trusted_hash = "sha256:bb"', ].join("\n") ); st = hooks.status(); assert.equal(st.events.PreToolUse.trusted, true); assert.equal(st.events.Stop.trusted, true); assert.equal(st.events.SessionStart.trusted, false); assert.equal(st.trusted, false); }); it("refreshes a stale script, never touches an absent one, and keeps the command", () => { assert.equal(hooks.refreshScript(), "absent"); hooks.install(); const before = read(); const script = path.join(HOME, "agenthub-hook.sh"); const stale = fs.readFileSync(script, "utf8").replace(/'[^']*hook-handler\.js'/, "'/old/h.js'"); fs.writeFileSync(script, stale); assert.equal(hooks.refreshScript(), "written"); assert.ok(fs.readFileSync(script, "utf8").includes(path.resolve(__dirname, "..", ".."))); assert.equal(hooks.refreshScript(), "exists"); fs.chmodSync(script, 0o644); assert.equal(hooks.refreshScript(), "written"); assert.equal(fs.statSync(script).mode & 0o111, 0o111); assert.deepEqual(read(), before); // A file by that name that is not ours is left alone. fs.writeFileSync(script, "#!/bin/sh\necho mine\n"); assert.equal(hooks.refreshScript(), "foreign"); assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n"); }); it("never removes a user's hook that shares our group or has a similar name", () => { hooks.install(); const file = read(); const mine = { type: "command", command: "my-notify.sh" }; file.hooks.Stop[0].hooks.push(mine); const lookalike = { hooks: [{ type: "command", command: "/home/me/bin/not-agenthub-hook.sh" }], }; file.hooks.PreToolUse.unshift(lookalike); fs.writeFileSync(FILE, JSON.stringify(file)); assert.equal(hooks.status().events.PreToolUse.installed, true); hooks.install(); let after = read(); assert.deepEqual(after.hooks.PreToolUse[0], lookalike); assert.deepEqual(after.hooks.Stop[0].hooks, [mine]); assert.equal(after.hooks.Stop.length, 2); hooks.remove(); after = read(); assert.deepEqual(after.hooks, { PreToolUse: [lookalike], Stop: [{ hooks: [mine] }] }); }); it("reads trust keyed on the resolved home when CODEX_HOME is a symlink", () => { const link = path.join(ROOT, "codex-link"); fs.rmSync(link, { force: true }); fs.symlinkSync(HOME, link); process.env.CODEX_HOME = link; try { hooks.install(); fs.writeFileSync( path.join(HOME, "config.toml"), `[hooks.state."${FILE}:stop:0:0"]\ntrusted_hash = "sha256:bb"\n` ); assert.equal(hooks.status().events.Stop.trusted, true); } finally { process.env.CODEX_HOME = HOME; } }); it("writes through a symlinked hooks.json instead of replacing the link", () => { const real = path.join(ROOT, "dotfiles-hooks.json"); fs.writeFileSync(real, JSON.stringify({ hooks: { PreToolUse: [theirs] } })); fs.symlinkSync(real, FILE); hooks.install(); assert.equal(fs.lstatSync(FILE).isSymbolicLink(), true); assert.equal(JSON.parse(fs.readFileSync(real, "utf8")).hooks.Stop.length, 1); fs.rmSync(real); }); it("single-quotes every value it writes into the script", () => { assert.equal(hooks.shq("/a b/it's"), `'/a b/it'\\''s'`); }); it("never overwrites or removes a script that is not ours, and keeps hooks.json's mode", () => { const script = path.join(HOME, "agenthub-hook.sh"); fs.writeFileSync(script, "#!/bin/sh\necho mine\n"); assert.throws(() => hooks.install(), { code: "EFOREIGN" }); assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n"); 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({ hooks: { Stop: [theirs] } }), { mode: 0o600 }); hooks.install(); assert.equal(fs.statSync(FILE).mode & 0o777, 0o600); hooks.remove(); assert.equal(fs.statSync(FILE).mode & 0o777, 0o600); }); it("empties a symlinked hooks.json through the link instead of unlinking it", () => { const real = path.join(ROOT, "dotfiles-hooks.json"); fs.writeFileSync(real, "{}\n"); fs.symlinkSync(real, FILE); hooks.install(); hooks.remove(); assert.ok(fs.lstatSync(FILE).isSymbolicLink()); assert.deepEqual(JSON.parse(fs.readFileSync(real, "utf8")), { hooks: {} }); }); });