Files
AgentHub/server/__tests__/codex-hooks.test.js
T
nntrivi2001 969b06253c feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from
Settings → Dashboard to the Claude Code tab's AgentHub sub-tab
(ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub.
A home that is or contains the OS home is refused (EBADHOME).

Settings persistence: dashboard-saved settings go to
~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin
cache an update wipes. Load order: real env > the checkout's .env > that file.
The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put.

C1: codex-usage.js reads Codex rollout journals into sessions (new column
tool) and token_usage, tracked per file in codex_rollouts; forks deduped by
total vector, sub-agent threads folded into the root; pricing-sync also
prices OpenAI models; PricingSettings with a vendor filter.

C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on
a click, through a stable forwarder; Codex trust is never written. The hook
route creates tool='codex' sessions and folds sub-agent hooks into the root.

D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens
from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js
installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never
PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab;
Gemini pricing.

Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing
on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=,
clear-data covering the new tables, Codex child-thread events moving no
status, hooks owning status only while current, EFOREIGN for a forwarder
that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE
honoured from .env.

Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
2026-09-29 16:09:19 +07:00

222 lines
8.7 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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: {} });
});
});