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.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user