Files
AgentHub/server/__tests__/antigravity-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

141 lines
5.5 KiB
JavaScript

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