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.
287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
/**
|
|
* @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"));
|
|
});
|
|
});
|