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.
232 lines
9.6 KiB
JavaScript
232 lines
9.6 KiB
JavaScript
/**
|
|
* @file Tests POST /api/hooks/event for hooks marked `tool: "antigravity"`
|
|
* (sent by agy through server/lib/antigravity-hooks.js): the conversation
|
|
* becomes an Antigravity session with the workspace as its cwd, a run's first
|
|
* PreInvocation is the prompt (the lane goes running) and later ones are
|
|
* dropped, PostToolUse lands in the timeline, Stop sets the lane idle, a
|
|
* claude lane at the same directory is untouched, and a hook from the
|
|
* Antigravity IDE (transcript outside the CLI's directory) is ignored; a turn
|
|
* after a Stop, a Stop with background work (ignored), a failed run (error),
|
|
* and a symlinked Gemini dir. Plus the settings routes. The Gemini dir and the DB live in a temp dir.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
const fs = require("node:fs");
|
|
const http = require("node:http");
|
|
|
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-hook-route-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
|
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db, stmts } = require("../db");
|
|
const lanes = require("../lib/lanes");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
function request(method, urlPath, body, headers = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlPath, BASE);
|
|
const req = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname,
|
|
method,
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
},
|
|
(res) => {
|
|
let text = "";
|
|
res.on("data", (c) => (text += c));
|
|
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text || "{}") }));
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (body) req.write(JSON.stringify(body));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
before(async () => {
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
after(() => {
|
|
server.close();
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
const CLI = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity-cli");
|
|
/** A hook exactly as agy 1.2.13 sends it. */
|
|
function agyHook(hook_type, id, cwd, extra = {}, dir = CLI) {
|
|
return request("POST", "/api/hooks/event", {
|
|
hook_type,
|
|
tool: "antigravity",
|
|
data: {
|
|
conversationId: id,
|
|
workspacePaths: [cwd],
|
|
transcriptPath: path.join(dir, "brain", id, ".system_generated/logs/transcript_full.jsonl"),
|
|
artifactDirectoryPath: path.join(dir, "brain", id),
|
|
modelName: "gemini-3.8-flash-high",
|
|
...extra,
|
|
},
|
|
});
|
|
}
|
|
const eventsOf = (sid) =>
|
|
db.prepare("SELECT event_type, tool_name FROM events WHERE session_id = ? ORDER BY id").all(sid);
|
|
|
|
describe("antigravity hooks through /api/hooks/event", () => {
|
|
it("runs a turn on the antigravity lane only, and records its tool calls", async () => {
|
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
|
const agyLane = lanes.createLane({ title: "a", cwd, tool: "antigravity" });
|
|
const claudeLane = lanes.createLane({ title: "c", cwd, tool: "claude" });
|
|
const id = "64389dfc-a91a-47d1-92f5-e153001026c7";
|
|
|
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 0, initialNumSteps: 1 });
|
|
const s = stmts.getSession.get(id);
|
|
assert.equal(s.tool, "antigravity");
|
|
assert.equal(s.cwd, cwd);
|
|
assert.equal(s.name, `Antigravity ${id.slice(0, 8)}`);
|
|
assert.equal(s.transcript_path, null);
|
|
assert.equal(lanes.getLane(agyLane.id).status, "running");
|
|
assert.equal(lanes.getLane(agyLane.id).session_id, id);
|
|
|
|
const later = await agyHook("PreInvocation", id, cwd, { invocationNum: 1 });
|
|
assert.equal(later.body.ignored, true);
|
|
const call = { name: "view_file", args: { AbsolutePath: `${cwd}/note.txt` } };
|
|
await agyHook("PostToolUse", id, cwd, { stepIdx: 2, error: "", toolCall: call });
|
|
await agyHook("PostToolUse", id, cwd, { stepIdx: 3, error: "", toolCall: call });
|
|
await agyHook("PostInvocation", id, cwd, { invocationNum: 0 });
|
|
await agyHook("Stop", id, cwd, {
|
|
executionNum: 0,
|
|
terminationReason: "NO_TOOL_CALL",
|
|
fullyIdle: true,
|
|
error: "",
|
|
});
|
|
assert.deepEqual(
|
|
eventsOf(id).map((e) => [e.event_type, e.tool_name]),
|
|
[
|
|
["UserPromptSubmit", null],
|
|
["PostToolUse", "view_file"],
|
|
["PostToolUse", "view_file"],
|
|
["Stop", null],
|
|
]
|
|
);
|
|
assert.equal(lanes.getLane(agyLane.id).status, "idle");
|
|
const c = lanes.getLane(claudeLane.id);
|
|
assert.equal(c.session_id, null);
|
|
assert.equal(c.status, claudeLane.status);
|
|
});
|
|
|
|
it("ignores the Antigravity IDE's hooks and a malformed conversation id", async () => {
|
|
const ide = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity");
|
|
const id = "11111111-2222-3333-4444-555555555555";
|
|
const r = await agyHook("PreInvocation", id, "/w", { invocationNum: 0 }, ide);
|
|
assert.equal(r.body.ignored, true);
|
|
assert.equal(stmts.getSession.get(id), undefined);
|
|
const bad = await agyHook("Stop", "../../x", "/w");
|
|
assert.equal(bad.body.ignored, true);
|
|
const noData = await request("POST", "/api/hooks/event", {
|
|
hook_type: "Stop",
|
|
tool: "antigravity",
|
|
data: "x",
|
|
});
|
|
assert.equal(noData.body.ignored, true);
|
|
});
|
|
});
|
|
|
|
describe("antigravity turns, errors and a symlinked Gemini dir", () => {
|
|
it("starts a later turn after a Stop, whatever its invocationNum", async () => {
|
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
|
const lane = lanes.createLane({ title: "t", cwd, tool: "antigravity" });
|
|
const id = "22222222-2222-4222-8222-222222222222";
|
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 0 });
|
|
await agyHook("Stop", id, cwd, { fullyIdle: true, error: "" });
|
|
assert.equal(lanes.getLane(lane.id).status, "idle");
|
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 4 });
|
|
assert.equal(lanes.getLane(lane.id).status, "running");
|
|
assert.deepEqual(
|
|
eventsOf(id).map((e) => e.event_type),
|
|
["UserPromptSubmit", "Stop", "UserPromptSubmit"]
|
|
);
|
|
});
|
|
|
|
it("keeps a turn's own model calls out, but not the prompt after a turn with no Stop", async () => {
|
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
|
lanes.createLane({ title: "n", cwd, tool: "antigravity" });
|
|
const id = "55555555-5555-4555-8555-555555555555";
|
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 0 });
|
|
const call = { name: "view_file", args: {} };
|
|
await agyHook("PostToolUse", id, cwd, { stepIdx: 1, error: "", toolCall: call });
|
|
const inTurn = await agyHook("PreInvocation", id, cwd, { invocationNum: 1 });
|
|
assert.equal(inTurn.body.ignored, true);
|
|
// The run died on an API error (no Stop); the poll settled the session.
|
|
db.prepare("UPDATE sessions SET status = 'completed' WHERE id = ?").run(id);
|
|
const next = await agyHook("PreInvocation", id, cwd, { invocationNum: 2 });
|
|
assert.notEqual(next.body.ignored, true);
|
|
assert.deepEqual(
|
|
eventsOf(id).map((e) => e.event_type),
|
|
["UserPromptSubmit", "PostToolUse", "UserPromptSubmit"]
|
|
);
|
|
});
|
|
|
|
it("ignores a Stop while background work runs, and lands a failed run as an error", async () => {
|
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
|
const lane = lanes.createLane({ title: "e", cwd, tool: "antigravity" });
|
|
const id = "33333333-3333-4333-8333-333333333333";
|
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 0 });
|
|
const busy = await agyHook("Stop", id, cwd, { fullyIdle: false, error: "" });
|
|
assert.equal(busy.body.ignored, true);
|
|
assert.equal(lanes.getLane(lane.id).status, "running");
|
|
await agyHook("Stop", id, cwd, {
|
|
fullyIdle: true,
|
|
terminationReason: "ERROR",
|
|
error: "quota exceeded",
|
|
});
|
|
assert.equal(stmts.getSession.get(id).status, "error");
|
|
assert.equal(lanes.getLane(lane.id).status, "idle");
|
|
});
|
|
|
|
it("accepts a CLI hook reported under the real path of a symlinked Gemini dir", async () => {
|
|
const real = process.env.AGENTHUB_GEMINI_DIR;
|
|
const link = path.join(ROOT, "gemini-link");
|
|
fs.mkdirSync(real, { recursive: true });
|
|
fs.symlinkSync(real, link);
|
|
process.env.AGENTHUB_GEMINI_DIR = link;
|
|
try {
|
|
const id = "44444444-4444-4444-8444-444444444444";
|
|
const r = await agyHook("PreInvocation", id, "/w", { invocationNum: 0 }, CLI);
|
|
assert.notEqual(r.body.ignored, true);
|
|
assert.equal(stmts.getSession.get(id).tool, "antigravity");
|
|
} finally {
|
|
process.env.AGENTHUB_GEMINI_DIR = real;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("GET/POST/DELETE /api/settings/antigravity-hooks", () => {
|
|
it("installs, reports and removes AgentHub's named hook, and refuses another site", async () => {
|
|
const file = path.join(process.env.AGENTHUB_GEMINI_DIR, "config", "hooks.json");
|
|
let r = await request("POST", "/api/settings/antigravity-hooks", null, {
|
|
Origin: "https://evil.example",
|
|
});
|
|
assert.equal(r.status, 403);
|
|
assert.equal(fs.existsSync(file), false);
|
|
r = await request("POST", "/api/settings/antigravity-hooks");
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.installed, true);
|
|
assert.ok(JSON.parse(fs.readFileSync(file, "utf8")).agenthub);
|
|
r = await request("GET", "/api/settings/antigravity-hooks");
|
|
assert.equal(r.body.installed, true);
|
|
r = await request("DELETE", "/api/settings/antigravity-hooks");
|
|
assert.equal(r.body.installed, false);
|
|
assert.equal(fs.existsSync(file), false);
|
|
});
|
|
});
|