969b06253c
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.
222 lines
8.7 KiB
JavaScript
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: {} });
|
|
});
|
|
});
|