/** * @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ĩ */ 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")); }); });