/** * @file AgentHub's hooks in Codex (`$CODEX_HOME/hooks.json`), installed and * removed only on the user's click. Codex runs a hook only once the user * trusts its exact definition (Codex asks at startup, or `/hooks`), keyed on * that definition's hash — so the command is a STABLE path, * `'/agenthub-hook.sh' `, and what changes between AgentHub * builds is only that script's content: it runs this build's * `scripts/hook-handler.js codex` with the server's node and * CLAUDE_HOME, and every server start rewrites it when stale. AgentHub never * writes Codex's trust (`[hooks.state]` in `config.toml`) — it only reads it * to report — and never passes `--dangerously-bypass-hook-trust`. A script * at that path that is not AgentHub's is never overwritten or removed * (`EFOREIGN`), and a rewritten hooks.json keeps the mode the user gave it. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { atomicWriteFile } = require("./config-write"); const { getCodexHome, codexHomes } = require("./codex-home"); const { getClaudeHome } = require("./claude-home"); const HOOK_EVENTS = [ "SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse", "Stop", "Interrupt", // a declined permission or Esc ends the turn without a Stop "SessionEnd", ]; const SCRIPT = "agenthub-hook.sh"; // Codex clamps SessionEnd/Interrupt to 3 s with a startup warning; the handler // gives up after 2.5 s anyway. const TIMEOUT_S = 3; const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js"); const scriptPath = () => path.join(getCodexHome(), SCRIPT); const hooksPath = () => path.join(getCodexHome(), "hooks.json"); const MARKER = "# AgentHub's Codex hook forwarder."; /** POSIX single-quoting: safe inside a shell command whatever the value. */ function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; } const commandFor = (event) => `${shq(scriptPath())} ${event}`; function scriptBody() { return [ "#!/bin/sh", `${MARKER} Rewritten by AgentHub on every start; the`, "# hooks.json command pointing here stays the same, so Codex's trust holds.", `N=${shq(process.execPath)}; [ -x "$N" ] || N=node`, `CLAUDE_HOME=${shq(getClaudeHome())} exec "$N" ${shq(HANDLER)} "$1" codex`, "", ].join("\n"); } // Ours: exactly `'/agenthub-hook.sh' ` as commandFor quotes it // (any CODEX_HOME, earlier ones included) — never a user's hook that merely // mentions the name, and never a user's hook sharing a group with ours. const OURS = /^'(?:[^']|'\\'')*\/agenthub-hook\.sh' [A-Za-z]+$/; const isOurHook = (h) => typeof h?.command === "string" && OURS.test(h.command); function fail(code, message) { return Object.assign(new Error(message), { code }); } /** hooks.json as an object, `{hooks:{}}` when absent; throws EBADJSON otherwise. */ function readHooks() { let text; try { text = fs.readFileSync(hooksPath(), "utf8"); } catch (err) { if (err.code === "ENOENT") return { hooks: {} }; throw err; } let file; try { file = JSON.parse(text); } catch { file = null; } if (!file || typeof file !== "object" || Array.isArray(file)) { throw fail("EBADJSON", `${hooksPath()} is not a JSON object; fix or move it first`); } if (file.hooks === undefined) file.hooks = {}; if (!file.hooks || typeof file.hooks !== "object" || Array.isArray(file.hooks)) { throw fail("EBADJSON", `${hooksPath()}: "hooks" is not an object`); } return file; } /** Temp + rename, through a symlinked file to its target, keeping `mode`. */ function writeAtomic(file, text, mode, { keepMode = false } = {}) { let target = file; let existing = null; try { target = fs.realpathSync(file); existing = fs.statSync(target).mode & 0o777; } catch { /* not there yet */ } atomicWriteFile(target, text); // A user's own file keeps its mode (a 0600 hooks.json stays private). fs.chmodSync(target, keepMode && existing !== null ? existing : mode); } /** A symlink (a dotfiles repo's file): emptied through the link, never unlinked. */ function isLink(file) { try { return fs.lstatSync(file).isSymbolicLink(); } catch { return false; } } /** * Whether `file` is absent or is AgentHub's forwarder (line 2 starts with * `marker`). Unreadable, a directory, anything else: not ours, so it is left * alone (install answers EFOREIGN, remove skips it). */ function scriptIsOurs(file, marker) { try { return fs.readFileSync(file, "utf8").split("\n", 2)[1]?.startsWith(marker) ?? false; } catch (err) { return err.code === "ENOENT"; } } /** * The file with every hook of ours taken out; a group left empty by that is * dropped, and so is an event left with no group. */ function withoutOurs(file) { for (const [event, groups] of Object.entries(file.hooks)) { if (!Array.isArray(groups)) continue; const kept = []; for (const g of groups) { if (!Array.isArray(g?.hooks) || !g.hooks.some(isOurHook)) { kept.push(g); continue; } const hooks = g.hooks.filter((h) => !isOurHook(h)); if (hooks.length) kept.push({ ...g, hooks }); } if (kept.length) file.hooks[event] = kept; else delete file.hooks[event]; } return file; } function assertSupported() { if (process.platform === "win32") { throw fail("EUNSUPPORTED", "Codex hooks from AgentHub need a POSIX shell"); } } /** Install (or reinstall) AgentHub's hooks. @returns the new status. */ function install() { assertSupported(); const file = withoutOurs(readHooks()); for (const event of HOOK_EVENTS) { const groups = Array.isArray(file.hooks[event]) ? file.hooks[event] : []; groups.push({ hooks: [{ type: "command", command: commandFor(event), timeout: TIMEOUT_S }] }); file.hooks[event] = groups; } if (!scriptIsOurs(scriptPath(), MARKER)) { throw fail("EFOREIGN", `${scriptPath()} is not AgentHub's; move it first`); } fs.mkdirSync(getCodexHome(), { recursive: true }); writeAtomic(scriptPath(), scriptBody(), 0o755); writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true }); return status(); } /** Take AgentHub's hooks out; the user's own stay. @returns the new status. */ function remove() { const file = withoutOurs(readHooks()); const others = Object.keys(file).filter((k) => k !== "hooks"); if (Object.keys(file.hooks).length === 0 && others.length === 0 && !isLink(hooksPath())) { fs.rmSync(hooksPath(), { force: true }); } else if (fs.existsSync(hooksPath())) { writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true }); } if (scriptIsOurs(scriptPath(), MARKER)) fs.rmSync(scriptPath(), { force: true }); return status(); } const snake = (event) => event.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); /** * Per event: is our hook there, and does Codex's `config.toml` hold a trust * entry for its position (`":::"`, under the * home as given or resolved). A trust entry for an older definition would read * as trusted too; ours never changes. */ function status() { const base = { supported: process.platform !== "win32", path: hooksPath(), script: scriptPath(), }; let file; try { file = readHooks(); } catch (err) { return { ...base, readable: false, error: err.message, installed: false, trusted: false, events: {}, }; } let toml = ""; try { toml = fs.readFileSync(path.join(getCodexHome(), "config.toml"), "utf8"); } catch { /* no config.toml: nothing trusted */ } const files = codexHomes().map((h) => path.join(h, "hooks.json")); const events = {}; for (const event of HOOK_EVENTS) { const groups = Array.isArray(file.hooks[event]) ? file.hooks[event] : []; let at = null; groups.forEach((g, i) => { const j = Array.isArray(g?.hooks) ? g.hooks.findIndex((h) => h?.command === commandFor(event)) : -1; if (j >= 0 && !at) at = `${snake(event)}:${i}:${j}`; }); events[event] = { installed: at !== null, trusted: at !== null && files.some((f) => toml.includes(`"${f}:${at}"`)), }; } const all = (k) => HOOK_EVENTS.every((e) => events[e][k]); const installed = all("installed") && fs.existsSync(scriptPath()); return { ...base, readable: true, installed, trusted: installed && all("trusted"), events }; } /** * Rewrite the forwarder when it is installed and stale (a new build, node or * CLAUDE_HOME) or lost its exec bit. Never creates it — installing is the * user's call — and never touches a file by that name that is not ours. * @returns {"absent"|"foreign"|"exists"|"written"|"failed"} */ function refreshScript() { try { const current = fs.readFileSync(scriptPath(), "utf8"); if (!current.split("\n", 2)[1]?.startsWith(MARKER)) return "foreign"; const executable = (fs.statSync(scriptPath()).mode & 0o111) !== 0; if (current === scriptBody() && executable) return "exists"; writeAtomic(scriptPath(), scriptBody(), 0o755); return "written"; } catch (err) { return err.code === "ENOENT" ? "absent" : "failed"; } } module.exports = { HOOK_EVENTS, shq, writeAtomic, scriptIsOurs, isLink, commandFor, install, remove, status, refreshScript, };