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.
141 lines
5.5 KiB
JavaScript
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());
|
|
});
|
|
});
|