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,286 @@
|
||||
/**
|
||||
* @file Tests POST /api/hooks/event for hooks marked `tool: "codex"` (sent by
|
||||
* Codex through server/lib/codex-hooks.js): the session is created as a Codex
|
||||
* one with no transcript path, a PermissionRequest becomes the permission
|
||||
* Notification (awaiting flag + the codex lane's needs_action, never the claude
|
||||
* lane sharing the directory), no stage detection on a codex lane, and a
|
||||
* sub-agent thread's lifecycle hooks dropped while its tool calls land on the
|
||||
* root session, a main agent made for a session the journal poll imported first,
|
||||
* and the install/remove routes refusing another site. CODEX_HOME 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-codex-hook-route-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.CODEX_HOME = path.join(ROOT, "codex");
|
||||
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();
|
||||
});
|
||||
}
|
||||
const codexHook = (hook_type, data) =>
|
||||
request("POST", "/api/hooks/event", { hook_type, data, tool: "codex" });
|
||||
|
||||
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 DAY = path.join(process.env.CODEX_HOME, "sessions", "2026", "09", "29");
|
||||
function rollout(thread, root) {
|
||||
fs.mkdirSync(DAY, { recursive: true });
|
||||
const file = path.join(DAY, `rollout-2026-09-29T01-00-00-${thread}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ type: "session_meta", payload: { id: thread, session_id: root, cwd: "/w" } }) +
|
||||
"\n"
|
||||
);
|
||||
return file;
|
||||
}
|
||||
const eventsOf = (sid) =>
|
||||
db.prepare("SELECT event_type, summary FROM events WHERE session_id = ? ORDER BY id").all(sid);
|
||||
|
||||
describe("codex hooks through /api/hooks/event", () => {
|
||||
it("files a permission request as waiting even when its command says compress", async () => {
|
||||
await codexHook("UserPromptSubmit", { session_id: "cx-zip", cwd: "/w", prompt: "pack" });
|
||||
await codexHook("PermissionRequest", {
|
||||
session_id: "cx-zip",
|
||||
cwd: "/w",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "tar --compress -cf out.tgz dist" },
|
||||
});
|
||||
assert.equal(eventsOf("cx-zip").at(-1).event_type, "Notification");
|
||||
assert.ok(stmts.getSession.get("cx-zip").awaiting_input_since);
|
||||
});
|
||||
|
||||
it("does not let a sub-agent's tool call after the root's Stop resume the root", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||
const lane = lanes.createLane({ title: "k", cwd, tool: "codex" });
|
||||
rollout("cx-root2", "cx-root2");
|
||||
const kid = rollout("cx-kid2", "cx-root2");
|
||||
await codexHook("UserPromptSubmit", { session_id: "cx-root2", cwd, prompt: "go" });
|
||||
await codexHook("Stop", { session_id: "cx-root2", cwd });
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
const waiting = stmts.getSession.get("cx-root2").awaiting_input_since;
|
||||
assert.ok(waiting);
|
||||
const call = { session_id: "cx-kid2", transcript_path: kid, cwd, tool_name: "Bash" };
|
||||
await codexHook("PreToolUse", { ...call, tool_input: { command: "ls" } });
|
||||
await codexHook("PostToolUse", { ...call, tool_input: { command: "ls" } });
|
||||
assert.equal(stmts.getSession.get("cx-root2").awaiting_input_since, waiting);
|
||||
assert.notEqual(
|
||||
db.prepare("SELECT status FROM agents WHERE id = ?").get("cx-root2-main").status,
|
||||
"working"
|
||||
);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
assert.deepEqual(
|
||||
eventsOf("cx-root2").map((e) => e.event_type),
|
||||
["UserPromptSubmit", "Stop", "PreToolUse", "PostToolUse"]
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a Codex session with no transcript path, and never reads the rollout as a Claude transcript", async () => {
|
||||
const file = rollout("cx-1", "cx-1");
|
||||
const r = await codexHook("SessionStart", {
|
||||
session_id: "cx-1",
|
||||
transcript_path: file,
|
||||
cwd: "/w",
|
||||
model: "gpt-5.6-sol",
|
||||
source: "startup",
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const row = stmts.getSession.get("cx-1");
|
||||
assert.equal(row.tool, "codex");
|
||||
assert.equal(row.name, "Codex cx-1");
|
||||
assert.equal(row.transcript_path, null);
|
||||
assert.equal(row.status, "active");
|
||||
assert.ok(row.awaiting_input_since, "a fresh Codex session sits at its prompt");
|
||||
});
|
||||
|
||||
it("treats an unknown tool value as Claude", async () => {
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "cc-odd", cwd: "/w" },
|
||||
tool: "evil",
|
||||
});
|
||||
assert.equal(stmts.getSession.get("cc-odd").tool, "claude");
|
||||
});
|
||||
|
||||
it("turns a PermissionRequest into the permission Notification, on the codex lane only", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||
const claudeLane = lanes.createLane({ title: "c", cwd, tool: "claude" });
|
||||
const codexLane = lanes.createLane({ title: "x", cwd, tool: "codex" });
|
||||
await codexHook("UserPromptSubmit", { session_id: "cx-2", cwd, prompt: "go" });
|
||||
await codexHook("PermissionRequest", {
|
||||
session_id: "cx-2",
|
||||
cwd,
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "touch /etc/x", description: "write outside the sandbox" },
|
||||
});
|
||||
assert.ok(stmts.getSession.get("cx-2").awaiting_input_since);
|
||||
const last = eventsOf("cx-2").at(-1);
|
||||
assert.equal(last.event_type, "Notification");
|
||||
assert.equal(last.summary, "Codex needs your permission: write outside the sandbox");
|
||||
assert.equal(lanes.getLane(codexLane.id).needs_action, last.summary);
|
||||
assert.equal(lanes.getLane(codexLane.id).session_id, "cx-2");
|
||||
assert.equal(lanes.getLane(claudeLane.id).needs_action, null);
|
||||
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
|
||||
|
||||
await codexHook("PostToolUse", { session_id: "cx-2", cwd, tool_name: "Bash" });
|
||||
assert.equal(lanes.getLane(codexLane.id).needs_action, null);
|
||||
assert.equal(stmts.getSession.get("cx-2").awaiting_input_since, null);
|
||||
});
|
||||
|
||||
it("ends the turn on an Interrupt (a declined permission, or Esc)", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||
const lane = lanes.createLane({ title: "x", cwd, tool: "codex" });
|
||||
await codexHook("UserPromptSubmit", { session_id: "cx-int", cwd, prompt: "go" });
|
||||
await codexHook("PermissionRequest", {
|
||||
session_id: "cx-int",
|
||||
cwd,
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "rm -rf build" },
|
||||
});
|
||||
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||
await codexHook("Interrupt", { session_id: "cx-int", cwd });
|
||||
const after = lanes.getLane(lane.id);
|
||||
assert.equal(after.status, "idle");
|
||||
assert.equal(after.needs_action, null);
|
||||
assert.equal(stmts.getSession.get("cx-int").awaiting_reason, "stop");
|
||||
assert.equal(eventsOf("cx-int").at(-1).event_type, "Stop");
|
||||
});
|
||||
|
||||
it("never infers a stage on a codex lane", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||
const lane = lanes.createLane({ title: "x", cwd, tool: "codex" });
|
||||
await codexHook("PreToolUse", {
|
||||
session_id: "cx-3",
|
||||
cwd,
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "npm run test:server" },
|
||||
});
|
||||
const after = lanes.getLane(lane.id);
|
||||
assert.equal(after.detected_stage, null);
|
||||
assert.equal(after.status, "running");
|
||||
});
|
||||
|
||||
it("drops a sub-agent thread's lifecycle hooks and puts its tool calls on the root session", async () => {
|
||||
const child = rollout("cx-child", "cx-root");
|
||||
await codexHook("SessionStart", { session_id: "cx-root", cwd: "/w" });
|
||||
const stop = await codexHook("Stop", {
|
||||
session_id: "cx-child",
|
||||
transcript_path: child,
|
||||
cwd: "/w",
|
||||
});
|
||||
assert.equal(stop.status, 200);
|
||||
assert.equal(stop.body.ignored, true);
|
||||
await codexHook("PreToolUse", {
|
||||
session_id: "cx-child",
|
||||
transcript_path: child,
|
||||
cwd: "/w",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls" },
|
||||
});
|
||||
assert.equal(stmts.getSession.get("cx-child"), undefined);
|
||||
assert.equal(eventsOf("cx-root").at(-1).event_type, "PreToolUse");
|
||||
assert.equal(
|
||||
eventsOf("cx-root").some((e) => e.event_type === "Stop"),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("gives a session the journal poll imported first its main agent", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, tool, started_at, updated_at) VALUES ('cx-polled', 'p', 'completed', 'codex', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
|
||||
).run();
|
||||
await codexHook("PreToolUse", { session_id: "cx-polled", cwd: "/w", tool_name: "Bash" });
|
||||
const agent = stmts.getAgent.get("cx-polled-main");
|
||||
assert.ok(agent);
|
||||
assert.equal(agent.type, "main");
|
||||
});
|
||||
|
||||
it("does not trust a transcript_path outside CODEX_HOME to name a root session", async () => {
|
||||
const outside = path.join(ROOT, "rollout-2026-09-29T01-00-00-cx-x.jsonl");
|
||||
fs.writeFileSync(
|
||||
outside,
|
||||
JSON.stringify({ type: "session_meta", payload: { id: "cx-x", session_id: "victim" } }) + "\n"
|
||||
);
|
||||
await codexHook("PreToolUse", {
|
||||
session_id: "cx-x",
|
||||
transcript_path: outside,
|
||||
cwd: "/w",
|
||||
tool_name: "Bash",
|
||||
});
|
||||
assert.ok(stmts.getSession.get("cx-x"));
|
||||
assert.equal(stmts.getSession.get("victim"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET/POST/DELETE /api/settings/codex-hooks", () => {
|
||||
it("installs, reports and removes AgentHub's Codex hooks", async () => {
|
||||
let r = await request("GET", "/api/settings/codex-hooks");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.installed, false);
|
||||
r = await request("POST", "/api/settings/codex-hooks");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.installed, true);
|
||||
assert.ok(fs.existsSync(path.join(process.env.CODEX_HOME, "hooks.json")));
|
||||
r = await request("DELETE", "/api/settings/codex-hooks");
|
||||
assert.equal(r.body.installed, false);
|
||||
});
|
||||
|
||||
it("refuses an install or remove asked by another site", async () => {
|
||||
const evil = { Origin: "https://evil.example" };
|
||||
let r = await request("POST", "/api/settings/codex-hooks", null, evil);
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, "hooks.json")), false);
|
||||
r = await request("DELETE", "/api/settings/codex-hooks", null, evil);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("answers 400 EBADJSON for a hooks.json it cannot merge into", async () => {
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(path.join(process.env.CODEX_HOME, "hooks.json"), "[1]");
|
||||
const r = await request("POST", "/api/settings/codex-hooks");
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "EBADJSON");
|
||||
fs.rmSync(path.join(process.env.CODEX_HOME, "hooks.json"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user