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,231 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* @file Tests server/lib/antigravity-usage.js — Antigravity CLI sessions and
|
||||
* tokens from agy's per-conversation SQLite files: the protobuf reader, a
|
||||
* session created as `tool = 'antigravity'` with its name, cwd, model and
|
||||
* per-model tokens (input excluding cache reads, output including thinking),
|
||||
* an unchanged file never re-read, a quiet one settled to completed (and the
|
||||
* lane a run with no Stop left running set idle, unless a pane owns it), a
|
||||
* session the user deleted staying deleted, a non-antigravity row with the
|
||||
* same id never touched, the empty `-wal` of our own read-only open not being
|
||||
* activity, a hook-reported turn kept running through a long quiet tool call,
|
||||
* an error Stop's status kept, and a file that is not agy's read only once. The Gemini dir and the DB are temp dirs; the fixture
|
||||
* files are built in the shape agy 1.2.13 writes.
|
||||
* @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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-usage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const Database = require("better-sqlite3");
|
||||
const { db } = require("../db");
|
||||
const agy = require("../lib/antigravity-usage");
|
||||
const lanes = require("../lib/lanes");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const CLI = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity-cli");
|
||||
const CONV = path.join(CLI, "conversations");
|
||||
|
||||
// --- a minimal protobuf writer, enough for the fixtures ---
|
||||
function varint(n) {
|
||||
const out = [];
|
||||
while (n >= 0x80) {
|
||||
out.push((n % 128) | 0x80);
|
||||
n = Math.floor(n / 128);
|
||||
}
|
||||
out.push(n);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
const pb = (num, v) =>
|
||||
typeof v === "number"
|
||||
? Buffer.concat([varint(num * 8), varint(v)])
|
||||
: Buffer.concat([varint(num * 8 + 2), varint(v.length), Buffer.from(v)]);
|
||||
const msg = (...parts) => Buffer.concat(parts);
|
||||
|
||||
function generation({ model, input, output, cacheRead = 0, cacheWrite = 0 }) {
|
||||
const stats = msg(
|
||||
pb(1, 1318),
|
||||
pb(2, input),
|
||||
pb(3, output),
|
||||
...(cacheWrite ? [pb(4, cacheWrite)] : []),
|
||||
pb(5, cacheRead),
|
||||
pb(7, "bot-1"),
|
||||
pb(9, Math.floor(output / 2))
|
||||
);
|
||||
return msg(pb(2, ""), pb(4, "req"), pb(1, msg(pb(3, 1318), pb(4, stats), pb(19, model))));
|
||||
}
|
||||
|
||||
function conversation(
|
||||
id,
|
||||
{ cwd = "/work/app", gens = [], prompt = "Fix the login bug\nplease", wal = false } = {}
|
||||
) {
|
||||
fs.mkdirSync(CONV, { recursive: true });
|
||||
const file = path.join(CONV, `${id}.db`);
|
||||
fs.rmSync(file, { force: true });
|
||||
const c = new Database(file);
|
||||
c.exec(`CREATE TABLE trajectory_meta (trajectory_id text, cascade_id text, trajectory_type integer, source integer, PRIMARY KEY (trajectory_id));
|
||||
CREATE TABLE gen_metadata (idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx));
|
||||
CREATE TABLE trajectory_metadata_blob (id text DEFAULT "main", data blob, PRIMARY KEY (id));`);
|
||||
c.prepare("INSERT INTO trajectory_meta VALUES ('t1', ?, 4, 17)").run(id);
|
||||
c.prepare("INSERT INTO trajectory_metadata_blob VALUES ('main', ?)").run(
|
||||
msg(pb(1, msg(pb(2, `file://${cwd.replace(/ /g, "%20")}`))))
|
||||
);
|
||||
gens.forEach((g, i) =>
|
||||
c.prepare("INSERT INTO gen_metadata VALUES (?, ?, 0)").run(i, generation(g))
|
||||
);
|
||||
// agy's files are WAL; a clean close deletes the -wal, as agy's exit does.
|
||||
if (wal) c.pragma("journal_mode = WAL");
|
||||
c.close();
|
||||
const logs = path.join(CLI, "brain", id, ".system_generated", "logs");
|
||||
fs.mkdirSync(logs, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(logs, "transcript_full.jsonl"),
|
||||
JSON.stringify({
|
||||
step_index: 0,
|
||||
source: "USER_EXPLICIT",
|
||||
type: "USER_INPUT",
|
||||
status: "DONE",
|
||||
created_at: "2026-09-29T04:38:44Z",
|
||||
content: `<USER_REQUEST>\n${prompt}\n</USER_REQUEST>\n<ADDITIONAL_METADATA>x</ADDITIONAL_METADATA>`,
|
||||
}) + "\n"
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
function age(id, ms) {
|
||||
const t = new Date(Date.now() - ms);
|
||||
fs.utimesSync(path.join(CONV, `${id}.db`), t, t);
|
||||
fs.utimesSync(
|
||||
path.join(CLI, "brain", id, ".system_generated", "logs", "transcript_full.jsonl"),
|
||||
t,
|
||||
t
|
||||
);
|
||||
}
|
||||
|
||||
const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
||||
const usage = (id) =>
|
||||
db
|
||||
.prepare(
|
||||
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
|
||||
)
|
||||
.all(id);
|
||||
|
||||
const ID = "57f3e409-9608-422b-8040-503f1bc7b5b8";
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.AGENTHUB_GEMINI_DIR, { recursive: true, force: true });
|
||||
db.exec(
|
||||
"DELETE FROM events; DELETE FROM agents; DELETE FROM token_usage; DELETE FROM sessions; DELETE FROM antigravity_conversations;"
|
||||
);
|
||||
});
|
||||
|
||||
const hookEvent = (id, type, msAgo) =>
|
||||
db
|
||||
.prepare("INSERT INTO events (session_id, event_type, created_at) VALUES (?, ?, ?)")
|
||||
.run(id, type, new Date(Date.now() - msAgo).toISOString());
|
||||
|
||||
describe("antigravity usage", () => {
|
||||
it("reads a generation's usage and model, and refuses a malformed buffer", () => {
|
||||
const u = agy.generationUsage(
|
||||
generation({ model: "gemini-3.8-flash", input: 12271, output: 153, cacheRead: 40 })
|
||||
);
|
||||
assert.deepEqual(u, {
|
||||
model: "gemini-3.8-flash",
|
||||
input: 12271,
|
||||
output: 153,
|
||||
cacheWrite: 0,
|
||||
cacheRead: 40,
|
||||
});
|
||||
assert.equal(agy.generationUsage(Buffer.from([0x0a, 0x7f, 1])), null);
|
||||
assert.equal(agy.generationUsage(msg(pb(4, "x"))), null);
|
||||
assert.equal(agy.firstFileUri(msg(pb(1, msg(pb(2, "file:///a%20b/c"))))), "/a b/c");
|
||||
});
|
||||
|
||||
it("creates the session with its name, cwd, model and per-model tokens", async () => {
|
||||
conversation(ID, {
|
||||
cwd: "/work/my app",
|
||||
gens: [
|
||||
{ model: "gemini-3.8-flash", input: 100, output: 10, cacheRead: 1000 },
|
||||
{ model: "gemini-3.8-flash", input: 50, output: 5, cacheWrite: 7 },
|
||||
{ model: "gemini-3.1-pro", input: 1, output: 2 },
|
||||
],
|
||||
});
|
||||
const r = await agy.ingestAntigravityUsage();
|
||||
assert.deepEqual(r, { files: 1, sessions: 1 });
|
||||
const s = session(ID);
|
||||
assert.equal(s.tool, "antigravity");
|
||||
assert.equal(s.name, "Fix the login bug");
|
||||
assert.equal(s.cwd, "/work/my app");
|
||||
assert.equal(s.model, "gemini-3.1-pro");
|
||||
assert.equal(s.started_at, "2026-09-29T04:38:44Z");
|
||||
assert.equal(s.status, "active");
|
||||
assert.equal(s.transcript_path, null);
|
||||
assert.deepEqual(usage(ID), [
|
||||
{
|
||||
model: "gemini-3.1-pro",
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
},
|
||||
{
|
||||
model: "gemini-3.8-flash",
|
||||
input_tokens: 150,
|
||||
output_tokens: 15,
|
||||
cache_read_tokens: 1000,
|
||||
cache_write_tokens: 7,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(await agy.ingestAntigravityUsage(), { files: 0, sessions: 0 });
|
||||
});
|
||||
|
||||
it("settles a session to completed once its file has gone quiet", async () => {
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
await agy.ingestAntigravityUsage();
|
||||
assert.equal(session(ID).status, "active");
|
||||
// Unchanged since the last read, just older: no re-read, only the status.
|
||||
const seen = db.prepare("SELECT mtime_ms FROM antigravity_conversations").get().mtime_ms;
|
||||
const r = await agy.ingestAntigravityUsage({ now: seen + 5 * 60_000 });
|
||||
assert.deepEqual(r, { files: 0, sessions: 1 });
|
||||
assert.equal(session(ID).status, "completed");
|
||||
// Read fresh when already old: completed straight away, ended at its last write.
|
||||
db.exec("DELETE FROM sessions; DELETE FROM antigravity_conversations;");
|
||||
age(ID, 10 * 60_000);
|
||||
await agy.ingestAntigravityUsage();
|
||||
assert.equal(session(ID).status, "completed");
|
||||
assert.ok(session(ID).ended_at);
|
||||
});
|
||||
|
||||
it("sets idle the lane a run with no Stop left running, once the session is quiet", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||
const lane = lanes.createLane({ title: "a", cwd, tool: "antigravity" });
|
||||
lanes.updateLane(lane.id, { status: "running", session_id: ID });
|
||||
const other = "11111111-2222-3333-4444-555555555555";
|
||||
const cwd2 = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||
const paned = lanes.createLane({ title: "b", cwd: cwd2, tool: "antigravity" });
|
||||
lanes.updateLane(paned.id, { status: "running", session_id: other, run_id: "agenthub-lane-9" });
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
conversation(other, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
const released = [];
|
||||
const broadcastLane = (id) => released.push(id);
|
||||
await agy.ingestAntigravityUsage({ broadcastLane });
|
||||
assert.equal(lanes.getLane(lane.id).status, "running"); // still being written
|
||||
const seen = db.prepare("SELECT max(mtime_ms) m FROM antigravity_conversations").get().m;
|
||||
await agy.ingestAntigravityUsage({ broadcastLane, now: seen + 5 * 60_000 });
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
assert.equal(lanes.getLane(paned.id).status, "running");
|
||||
assert.deepEqual(released, [lane.id]);
|
||||
});
|
||||
|
||||
it("keeps a deleted session deleted until agy writes to it again", async () => {
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
age(ID, 10 * 60_000);
|
||||
await agy.ingestAntigravityUsage();
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
|
||||
await agy.ingestAntigravityUsage();
|
||||
assert.equal(session(ID), undefined);
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 2, output: 1 }] });
|
||||
await agy.ingestAntigravityUsage();
|
||||
assert.equal(session(ID).tool, "antigravity");
|
||||
});
|
||||
|
||||
it("never touches a session of another tool with the same id", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, tool, started_at, updated_at) VALUES (?, 'mine', 'active', 'claude', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
|
||||
).run(ID);
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
await agy.ingestAntigravityUsage();
|
||||
assert.equal(session(ID).name, "mine");
|
||||
assert.deepEqual(usage(ID), []);
|
||||
});
|
||||
|
||||
it("keeps a hook-made session's cwd and a name the user saw, over a placeholder", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, tool, cwd, started_at, updated_at) VALUES (?, ?, 'active', 'antigravity', '/from/hook', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
|
||||
).run(ID, `Antigravity ${ID.slice(0, 8)}`);
|
||||
conversation(ID, {
|
||||
cwd: "/from/db",
|
||||
gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }],
|
||||
});
|
||||
await agy.ingestAntigravityUsage();
|
||||
assert.equal(session(ID).cwd, "/from/hook");
|
||||
assert.equal(session(ID).name, "Fix the login bug");
|
||||
});
|
||||
|
||||
it("skips a file that is not a conversation database, and carries on", async () => {
|
||||
fs.mkdirSync(CONV, { recursive: true });
|
||||
fs.writeFileSync(path.join(CONV, "00000000-0000-0000-0000-000000000000.db"), "not sqlite");
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
const r = await agy.ingestAntigravityUsage();
|
||||
assert.equal(r.sessions, 1);
|
||||
assert.ok(session(ID));
|
||||
// Recorded, so it is not re-read (nor warned about) every pass.
|
||||
assert.deepEqual(await agy.ingestAntigravityUsage(), { files: 0, sessions: 0 });
|
||||
});
|
||||
|
||||
it("does not take the empty -wal its own read-only open leaves for activity", async () => {
|
||||
conversation(ID, { wal: true, gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
age(ID, 10 * 60_000);
|
||||
await agy.ingestAntigravityUsage();
|
||||
const first = session(ID);
|
||||
assert.equal(first.status, "completed");
|
||||
assert.deepEqual(await agy.ingestAntigravityUsage(), { files: 0, sessions: 0 });
|
||||
assert.equal(session(ID).status, "completed");
|
||||
assert.equal(session(ID).updated_at, first.updated_at);
|
||||
});
|
||||
|
||||
it("keeps a turn the hooks report running through a long quiet tool call", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||
const lane = lanes.createLane({ title: "h", cwd, tool: "antigravity" });
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
age(ID, 10 * 60_000);
|
||||
await agy.ingestAntigravityUsage();
|
||||
// A hook three minutes ago: agy is mid-turn, its file untouched for ten.
|
||||
db.prepare("UPDATE sessions SET status = 'active', ended_at = NULL WHERE id = ?").run(ID);
|
||||
db.prepare(
|
||||
"INSERT INTO agents (id, session_id, name, type, status, started_at) VALUES (?, ?, 'Main', 'main', 'working', '2026-09-29T00:00:00Z')"
|
||||
).run(`${ID}-main`, ID);
|
||||
lanes.updateLane(lane.id, { status: "running", session_id: ID });
|
||||
hookEvent(ID, "PostToolUse", 3 * 60_000);
|
||||
await agy.ingestAntigravityUsage();
|
||||
assert.equal(session(ID).status, "active");
|
||||
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||
// Ten minutes after that hook, it is over.
|
||||
await agy.ingestAntigravityUsage({ now: Date.now() + 8 * 60_000 });
|
||||
assert.equal(session(ID).status, "completed");
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
assert.equal(
|
||||
db.prepare("SELECT status FROM agents WHERE id = ?").get(`${ID}-main`).status,
|
||||
"completed"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a session waiting after a Stop active for ten quiet minutes", async () => {
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
age(ID, 10 * 60_000);
|
||||
await agy.ingestAntigravityUsage();
|
||||
db.prepare("UPDATE sessions SET status = 'active', ended_at = NULL WHERE id = ?").run(ID);
|
||||
hookEvent(ID, "Stop", 3 * 60_000);
|
||||
await agy.ingestAntigravityUsage();
|
||||
assert.equal(session(ID).status, "active");
|
||||
await agy.ingestAntigravityUsage({ now: Date.now() + 8 * 60_000 });
|
||||
assert.equal(session(ID).status, "completed");
|
||||
});
|
||||
|
||||
it("gives a file it could not read another try after a restart", async () => {
|
||||
fs.mkdirSync(CONV, { recursive: true });
|
||||
const bad = path.join(CONV, "00000000-0000-0000-0000-000000000000.db");
|
||||
fs.writeFileSync(bad, "not sqlite");
|
||||
await agy.ingestAntigravityUsage();
|
||||
const seen = () => db.prepare("SELECT session_id FROM antigravity_conversations").all();
|
||||
assert.deepEqual(seen(), [{ session_id: "" }]);
|
||||
const saved = process.env.DASHBOARD_ANTIGRAVITY_POLL_MS;
|
||||
process.env.DASHBOARD_ANTIGRAVITY_POLL_MS = "3600000";
|
||||
try {
|
||||
agy.startAntigravityUsagePoll(
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
// Cleared before the first pass reads it again.
|
||||
assert.ok(seen().length <= 1);
|
||||
await agy.ingestAntigravityUsage();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.DASHBOARD_ANTIGRAVITY_POLL_MS;
|
||||
else process.env.DASHBOARD_ANTIGRAVITY_POLL_MS = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the error a failed run's Stop gave the session", async () => {
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||
await agy.ingestAntigravityUsage();
|
||||
db.prepare("UPDATE sessions SET status = 'error' WHERE id = ?").run(ID);
|
||||
hookEvent(ID, "Stop", 1000);
|
||||
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 2, output: 1 }] });
|
||||
await agy.ingestAntigravityUsage();
|
||||
assert.equal(session(ID).status, "error");
|
||||
assert.equal(usage(ID)[0].input_tokens, 2);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ const EXPECTED_API_PATHS = [
|
||||
"/api/settings/export",
|
||||
"/api/settings/cleanup",
|
||||
"/api/settings/claude-home",
|
||||
"/api/settings/codex-home",
|
||||
"/api/import/guide",
|
||||
"/api/import/rescan",
|
||||
"/api/import/scan-path",
|
||||
@@ -277,6 +278,20 @@ describe("Sessions API", () => {
|
||||
res.body.sessions.forEach((s) => assert.equal(s.status, "active"));
|
||||
});
|
||||
|
||||
it("filters sessions by tool, before the limit", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, tool, started_at, updated_at) VALUES ('sess-cx', 'cx', 'active', 'codex', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
|
||||
).run();
|
||||
const codex = await fetch("/api/sessions?tool=codex&limit=1");
|
||||
assert.deepEqual(
|
||||
codex.body.sessions.map((s) => s.id),
|
||||
["sess-cx"]
|
||||
);
|
||||
const claude = await fetch("/api/sessions?tool=claude");
|
||||
assert.ok(claude.body.sessions.length >= 1);
|
||||
assert.ok(claude.body.sessions.every((s) => s.tool === "claude"));
|
||||
});
|
||||
|
||||
it("should paginate sessions", async () => {
|
||||
const res = await fetch("/api/sessions?limit=1&offset=0");
|
||||
assert.equal(res.body.sessions.length, 1);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @file Tests setClaudeHome: the shared home-dir guard (a directory that is or
|
||||
* contains the user's home is refused), persistence to the settings file
|
||||
* (never the repo `.env`, which a plugin update wipes), and pinning the data
|
||||
* dir, so a new CLAUDE_HOME can never open an empty database at the next start.
|
||||
* The settings file is a temp path (AGENTHUB_SETTINGS_FILE).
|
||||
* @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 { describe, it, before, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-claude-home-set-"));
|
||||
const SETTINGS = path.join(ROOT, "settings.env");
|
||||
const REPO_ENV = path.resolve(__dirname, "..", "..", ".env");
|
||||
const SAVED = {
|
||||
AGENTHUB_SETTINGS_FILE: process.env.AGENTHUB_SETTINGS_FILE,
|
||||
CLAUDE_HOME: process.env.CLAUDE_HOME,
|
||||
DASHBOARD_DATA_DIR: process.env.DASHBOARD_DATA_DIR,
|
||||
};
|
||||
process.env.AGENTHUB_SETTINGS_FILE = SETTINGS;
|
||||
|
||||
const { setClaudeHome, getClaudeHome, getDataDir } = require("../lib/claude-home");
|
||||
|
||||
describe("setClaudeHome", () => {
|
||||
let repoEnvBefore;
|
||||
before(() => {
|
||||
repoEnvBefore = fs.existsSync(REPO_ENV) ? fs.readFileSync(REPO_ENV, "utf8") : null;
|
||||
});
|
||||
beforeEach(() => {
|
||||
fs.rmSync(SETTINGS, { force: true });
|
||||
process.env.CLAUDE_HOME = fs.mkdtempSync(path.join(ROOT, "start-"));
|
||||
delete process.env.DASHBOARD_DATA_DIR;
|
||||
});
|
||||
after(() => {
|
||||
for (const [k, v] of Object.entries(SAVED)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
for (const bad of ["/", os.homedir(), path.dirname(os.homedir()), "~/"]) {
|
||||
it(`refuses ${JSON.stringify(bad)} as too broad`, () => {
|
||||
const current = getClaudeHome();
|
||||
assert.throws(() => setClaudeHome(bad), { code: "EBADHOME" });
|
||||
assert.equal(getClaudeHome(), current);
|
||||
assert.ok(!fs.existsSync(SETTINGS));
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts a real directory, persisting it to the settings file only", () => {
|
||||
const next = fs.mkdtempSync(path.join(ROOT, "next-"));
|
||||
assert.equal(setClaudeHome(next), next);
|
||||
assert.equal(getClaudeHome(), next);
|
||||
assert.match(fs.readFileSync(SETTINGS, "utf8"), new RegExp(`^CLAUDE_HOME=${next}$`, "m"));
|
||||
const repoEnvNow = fs.existsSync(REPO_ENV) ? fs.readFileSync(REPO_ENV, "utf8") : null;
|
||||
assert.equal(repoEnvNow, repoEnvBefore, "the repo .env is never written");
|
||||
});
|
||||
|
||||
it("pins the data dir in use before CLAUDE_HOME moves, so the database stays put", () => {
|
||||
const dataDirBefore = getDataDir();
|
||||
setClaudeHome(fs.mkdtempSync(path.join(ROOT, "next-")));
|
||||
assert.equal(getDataDir(), dataDirBefore);
|
||||
assert.equal(process.env.DASHBOARD_DATA_DIR, dataDirBefore);
|
||||
assert.match(
|
||||
fs.readFileSync(SETTINGS, "utf8"),
|
||||
new RegExp(`^DASHBOARD_DATA_DIR=${dataDirBefore}$`, "m")
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an explicit DASHBOARD_DATA_DIR alone", () => {
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "explicit-data");
|
||||
setClaudeHome(fs.mkdtempSync(path.join(ROOT, "next-")));
|
||||
assert.equal(process.env.DASHBOARD_DATA_DIR, path.join(ROOT, "explicit-data"));
|
||||
assert.doesNotMatch(fs.readFileSync(SETTINGS, "utf8"), /DASHBOARD_DATA_DIR/);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @file Tests server/lib/codex-hooks.js — installing AgentHub's hooks into
|
||||
* Codex's `$CODEX_HOME/hooks.json`: a user's own hooks kept, a reinstall
|
||||
* replacing only ours, remove leaving theirs, a non-JSON file refused, trust
|
||||
* read from `config.toml` (also under a symlinked home), a user's hook in our
|
||||
* group or with a similar name never removed with ours, and the forwarding
|
||||
* script refreshed — only when it is ours — without its command (which Codex's
|
||||
* trust is keyed on) changing. CODEX_HOME 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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-hooks-"));
|
||||
process.env.CODEX_HOME = path.join(ROOT, "codex");
|
||||
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/codex-hooks");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const HOME = process.env.CODEX_HOME;
|
||||
const FILE = path.join(HOME, "hooks.json");
|
||||
const read = () => JSON.parse(fs.readFileSync(FILE, "utf8"));
|
||||
const theirs = { hooks: [{ type: "command", command: "my-audit.sh", timeout: 10 }] };
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(HOME, { recursive: true });
|
||||
});
|
||||
|
||||
describe("codex hooks", () => {
|
||||
it("installs one group per event, all through the stable script", () => {
|
||||
const st = hooks.install();
|
||||
assert.equal(st.installed, true);
|
||||
const file = read();
|
||||
assert.deepEqual(Object.keys(file.hooks).sort(), [...hooks.HOOK_EVENTS].sort());
|
||||
for (const e of hooks.HOOK_EVENTS) {
|
||||
assert.deepEqual(file.hooks[e], [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `'${path.join(HOME, "agenthub-hook.sh")}' ${e}`,
|
||||
timeout: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
const script = fs.readFileSync(path.join(HOME, "agenthub-hook.sh"), "utf8");
|
||||
assert.match(script, /^#!\/bin\/sh\n/);
|
||||
assert.ok(script.includes(path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js")));
|
||||
assert.ok(script.includes(`CLAUDE_HOME='${process.env.CLAUDE_HOME}'`));
|
||||
assert.ok(script.includes('"$1" codex'));
|
||||
assert.equal(fs.statSync(path.join(HOME, "agenthub-hook.sh")).mode & 0o111, 0o111);
|
||||
});
|
||||
|
||||
it("keeps the user's own hooks, and a reinstall replaces only ours", () => {
|
||||
fs.writeFileSync(
|
||||
FILE,
|
||||
JSON.stringify({ description: "mine", hooks: { PreToolUse: [theirs], Notify: [theirs] } })
|
||||
);
|
||||
hooks.install();
|
||||
hooks.install();
|
||||
const file = read();
|
||||
assert.equal(file.description, "mine");
|
||||
assert.deepEqual(file.hooks.Notify, [theirs]);
|
||||
assert.equal(file.hooks.PreToolUse.length, 2);
|
||||
assert.deepEqual(file.hooks.PreToolUse[0], theirs);
|
||||
assert.equal(file.hooks.Stop.length, 1);
|
||||
});
|
||||
|
||||
it("remove takes out only ours, and deletes a file that held nothing else", () => {
|
||||
fs.writeFileSync(FILE, JSON.stringify({ hooks: { PreToolUse: [theirs] } }));
|
||||
hooks.install();
|
||||
let st = hooks.remove();
|
||||
assert.equal(st.installed, false);
|
||||
assert.deepEqual(read(), { hooks: { PreToolUse: [theirs] } });
|
||||
assert.equal(fs.existsSync(path.join(HOME, "agenthub-hook.sh")), false);
|
||||
|
||||
fs.rmSync(FILE);
|
||||
hooks.install();
|
||||
st = hooks.remove();
|
||||
assert.equal(fs.existsSync(FILE), false);
|
||||
assert.equal(st.installed, false);
|
||||
});
|
||||
|
||||
it("refuses a hooks.json that is not a JSON object, and changes nothing", () => {
|
||||
fs.writeFileSync(FILE, "{ not json");
|
||||
assert.throws(() => hooks.install(), { code: "EBADJSON" });
|
||||
assert.equal(fs.readFileSync(FILE, "utf8"), "{ not json");
|
||||
assert.equal(hooks.status().readable, false);
|
||||
});
|
||||
|
||||
it("reports trust from Codex's own hooks.state entries", () => {
|
||||
fs.writeFileSync(FILE, JSON.stringify({ hooks: { PreToolUse: [theirs] } }));
|
||||
hooks.install();
|
||||
let st = hooks.status();
|
||||
assert.equal(st.events.PreToolUse.trusted, false);
|
||||
// Ours is the second PreToolUse group (index 1); Stop's is the first.
|
||||
fs.writeFileSync(
|
||||
path.join(HOME, "config.toml"),
|
||||
[
|
||||
'model = "x"',
|
||||
"",
|
||||
`[hooks.state."${FILE}:pre_tool_use:1:0"]`,
|
||||
'trusted_hash = "sha256:aa"',
|
||||
`[hooks.state."${FILE}:stop:0:0"]`,
|
||||
'trusted_hash = "sha256:bb"',
|
||||
].join("\n")
|
||||
);
|
||||
st = hooks.status();
|
||||
assert.equal(st.events.PreToolUse.trusted, true);
|
||||
assert.equal(st.events.Stop.trusted, true);
|
||||
assert.equal(st.events.SessionStart.trusted, false);
|
||||
assert.equal(st.trusted, false);
|
||||
});
|
||||
|
||||
it("refreshes a stale script, never touches an absent one, and keeps the command", () => {
|
||||
assert.equal(hooks.refreshScript(), "absent");
|
||||
hooks.install();
|
||||
const before = read();
|
||||
const script = path.join(HOME, "agenthub-hook.sh");
|
||||
const stale = fs.readFileSync(script, "utf8").replace(/'[^']*hook-handler\.js'/, "'/old/h.js'");
|
||||
fs.writeFileSync(script, stale);
|
||||
assert.equal(hooks.refreshScript(), "written");
|
||||
assert.ok(fs.readFileSync(script, "utf8").includes(path.resolve(__dirname, "..", "..")));
|
||||
assert.equal(hooks.refreshScript(), "exists");
|
||||
fs.chmodSync(script, 0o644);
|
||||
assert.equal(hooks.refreshScript(), "written");
|
||||
assert.equal(fs.statSync(script).mode & 0o111, 0o111);
|
||||
assert.deepEqual(read(), before);
|
||||
// A file by that name that is not ours is left alone.
|
||||
fs.writeFileSync(script, "#!/bin/sh\necho mine\n");
|
||||
assert.equal(hooks.refreshScript(), "foreign");
|
||||
assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n");
|
||||
});
|
||||
|
||||
it("never removes a user's hook that shares our group or has a similar name", () => {
|
||||
hooks.install();
|
||||
const file = read();
|
||||
const mine = { type: "command", command: "my-notify.sh" };
|
||||
file.hooks.Stop[0].hooks.push(mine);
|
||||
const lookalike = {
|
||||
hooks: [{ type: "command", command: "/home/me/bin/not-agenthub-hook.sh" }],
|
||||
};
|
||||
file.hooks.PreToolUse.unshift(lookalike);
|
||||
fs.writeFileSync(FILE, JSON.stringify(file));
|
||||
assert.equal(hooks.status().events.PreToolUse.installed, true);
|
||||
hooks.install();
|
||||
let after = read();
|
||||
assert.deepEqual(after.hooks.PreToolUse[0], lookalike);
|
||||
assert.deepEqual(after.hooks.Stop[0].hooks, [mine]);
|
||||
assert.equal(after.hooks.Stop.length, 2);
|
||||
hooks.remove();
|
||||
after = read();
|
||||
assert.deepEqual(after.hooks, { PreToolUse: [lookalike], Stop: [{ hooks: [mine] }] });
|
||||
});
|
||||
|
||||
it("reads trust keyed on the resolved home when CODEX_HOME is a symlink", () => {
|
||||
const link = path.join(ROOT, "codex-link");
|
||||
fs.rmSync(link, { force: true });
|
||||
fs.symlinkSync(HOME, link);
|
||||
process.env.CODEX_HOME = link;
|
||||
try {
|
||||
hooks.install();
|
||||
fs.writeFileSync(
|
||||
path.join(HOME, "config.toml"),
|
||||
`[hooks.state."${FILE}:stop:0:0"]\ntrusted_hash = "sha256:bb"\n`
|
||||
);
|
||||
assert.equal(hooks.status().events.Stop.trusted, true);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = HOME;
|
||||
}
|
||||
});
|
||||
|
||||
it("writes through a symlinked hooks.json instead of replacing the link", () => {
|
||||
const real = path.join(ROOT, "dotfiles-hooks.json");
|
||||
fs.writeFileSync(real, JSON.stringify({ hooks: { PreToolUse: [theirs] } }));
|
||||
fs.symlinkSync(real, FILE);
|
||||
hooks.install();
|
||||
assert.equal(fs.lstatSync(FILE).isSymbolicLink(), true);
|
||||
assert.equal(JSON.parse(fs.readFileSync(real, "utf8")).hooks.Stop.length, 1);
|
||||
fs.rmSync(real);
|
||||
});
|
||||
|
||||
it("single-quotes every value it writes into the script", () => {
|
||||
assert.equal(hooks.shq("/a b/it's"), `'/a b/it'\\''s'`);
|
||||
});
|
||||
|
||||
it("never overwrites or removes a script that is not ours, and keeps hooks.json's mode", () => {
|
||||
const script = path.join(HOME, "agenthub-hook.sh");
|
||||
fs.writeFileSync(script, "#!/bin/sh\necho mine\n");
|
||||
assert.throws(() => hooks.install(), { code: "EFOREIGN" });
|
||||
assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n");
|
||||
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({ hooks: { Stop: [theirs] } }), { mode: 0o600 });
|
||||
hooks.install();
|
||||
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
|
||||
hooks.remove();
|
||||
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
it("empties a symlinked hooks.json through the link instead of unlinking it", () => {
|
||||
const real = path.join(ROOT, "dotfiles-hooks.json");
|
||||
fs.writeFileSync(real, "{}\n");
|
||||
fs.symlinkSync(real, FILE);
|
||||
hooks.install();
|
||||
hooks.remove();
|
||||
assert.ok(fs.lstatSync(FILE).isSymbolicLink());
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(real, "utf8")), { hooks: {} });
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,40 @@ describe("codex status mirror", () => {
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("keeps a lane running while an older journal of the same lane sits beside the live one", () => {
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd, { mtime: Date.now() - 6 * 60 * 60 * 1000 });
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("counts a journal whose mtime is a fraction of a second ahead as fresh", () => {
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd, { mtime: Date.now() + 400 });
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("leaves a lane alone while its session's hooks are current", () => {
|
||||
const { stmts } = require("../db");
|
||||
const lane = codexLane();
|
||||
lanes.updateLane(lane.id, { session_id: "hooked" });
|
||||
stmts.insertSession.run("hooked", "x", "active", lane.cwd, null, null);
|
||||
stmts.insertEvent.run("hooked", null, "Stop", null, "ready", "{}");
|
||||
writeRollout(lane.cwd); // fresh journal: the mirror alone would say running
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("a second pass with nothing new changes nothing", () => {
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* @file Tests server/lib/codex-usage.js — Codex sessions and token usage read
|
||||
* from rollout journals: the token mapping (input includes cached, output
|
||||
* includes reasoning), duplicate totals, a mid-session model switch, a fork's
|
||||
* copied history skipped, a sub-agent thread folded into its root session,
|
||||
* incremental reads, a deleted session not resurrected, and a Claude row never
|
||||
* touched. CODEX_HOME and the DB live in 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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-usage-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.CODEX_HOME = path.join(ROOT, "codex");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { db, stmts } = require("../db");
|
||||
const { parseRolloutChunk, ingestCodexUsage, settleActive } = require("../lib/codex-usage");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const DAY = path.join(process.env.CODEX_HOME, "sessions", "2026", "09", "29");
|
||||
|
||||
function meta(id, extra = {}) {
|
||||
return {
|
||||
timestamp: "2026-09-29T01:00:00.000Z",
|
||||
type: "session_meta",
|
||||
payload: { id, session_id: id, timestamp: "2026-09-29T01:00:00.000Z", cwd: "/w/p", ...extra },
|
||||
};
|
||||
}
|
||||
const ctx = (model) => ({
|
||||
timestamp: "2026-09-29T01:00:01.000Z",
|
||||
type: "turn_context",
|
||||
payload: { model },
|
||||
});
|
||||
function tc(total, last, ts = "2026-09-29T01:00:02.000Z") {
|
||||
const v = (t) => ({
|
||||
input_tokens: t[0],
|
||||
cached_input_tokens: t[1],
|
||||
cache_write_input_tokens: 0,
|
||||
output_tokens: t[2],
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: t[0] + t[2],
|
||||
});
|
||||
return {
|
||||
timestamp: ts,
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: { total_token_usage: v(total), last_token_usage: v(last) },
|
||||
},
|
||||
};
|
||||
}
|
||||
const jsonl = (lines) => lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
||||
function writeRollout(id, lines, mtime = new Date("2026-09-29T02:00:00Z")) {
|
||||
fs.mkdirSync(DAY, { recursive: true });
|
||||
const file = path.join(DAY, `rollout-2026-09-29T01-00-00-${id}.jsonl`);
|
||||
fs.writeFileSync(file, jsonl(lines));
|
||||
fs.utimesSync(file, mtime, mtime);
|
||||
return file;
|
||||
}
|
||||
const usage = (sid) =>
|
||||
db
|
||||
.prepare(
|
||||
"SELECT model, input_tokens AS i, cache_read_tokens AS r, output_tokens AS o FROM token_usage WHERE session_id = ? ORDER BY model"
|
||||
)
|
||||
.all(sid);
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
|
||||
db.exec(
|
||||
"DELETE FROM codex_rollouts; DELETE FROM token_usage; DELETE FROM events; DELETE FROM sessions;"
|
||||
);
|
||||
});
|
||||
|
||||
describe("parseRolloutChunk", () => {
|
||||
it("maps tokens, skips a repeated total and a null info, and follows the model", () => {
|
||||
const text = jsonl([
|
||||
meta("t1"),
|
||||
ctx("gpt-a"),
|
||||
tc([100, 60, 10], [100, 60, 10]),
|
||||
tc([100, 60, 10], [100, 60, 10]), // emitted twice
|
||||
{ type: "event_msg", payload: { type: "token_count", info: null } },
|
||||
ctx("gpt-b"),
|
||||
tc([150, 90, 15], [50, 30, 5], "2026-09-29T01:05:00.000Z"),
|
||||
]);
|
||||
const s = parseRolloutChunk(text, {});
|
||||
assert.equal(s.threadId, "t1");
|
||||
assert.equal(s.sessionId, "t1");
|
||||
assert.equal(s.cwd, "/w/p");
|
||||
assert.equal(s.model, "gpt-b");
|
||||
assert.deepEqual(s.usage, {
|
||||
"gpt-a": { input: 40, cacheRead: 60, cacheWrite: 0, output: 10 },
|
||||
"gpt-b": { input: 20, cacheRead: 30, cacheWrite: 0, output: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("continues from saved state across chunks", () => {
|
||||
let s = parseRolloutChunk(jsonl([meta("t1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]), {});
|
||||
s = parseRolloutChunk(jsonl([tc([10, 0, 1], [10, 0, 1]), tc([30, 0, 3], [20, 0, 2])]), s);
|
||||
assert.deepEqual(s.usage["gpt-a"], { input: 30, cacheRead: 0, cacheWrite: 0, output: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingestCodexUsage", () => {
|
||||
it("creates a codex session with its usage, named from session_index.jsonl", async () => {
|
||||
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([100, 60, 10], [100, 60, 10])]);
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.CODEX_HOME, "session_index.jsonl"),
|
||||
JSON.stringify({ id: "s1", thread_name: "Fix the thing" }) + "\n"
|
||||
);
|
||||
const events = [];
|
||||
await ingestCodexUsage({
|
||||
broadcast: (t, d) => events.push([t, d.id]),
|
||||
now: Date.parse("2026-09-29T03:00:00Z"),
|
||||
});
|
||||
const row = stmts.getSession.get("s1");
|
||||
assert.equal(row.tool, "codex");
|
||||
assert.equal(row.name, "Fix the thing");
|
||||
assert.equal(row.cwd, "/w/p");
|
||||
assert.equal(row.model, "gpt-a");
|
||||
assert.equal(row.status, "completed");
|
||||
assert.equal(row.started_at, "2026-09-29T01:00:00.000Z");
|
||||
assert.equal(row.ended_at, "2026-09-29T02:00:00.000Z"); // the journal's mtime
|
||||
assert.equal(row.updated_at, "2026-09-29T02:00:00.000Z"); // last activity, not ingestion
|
||||
assert.equal(row.transcript_path, null);
|
||||
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 40, r: 60, o: 10 }]);
|
||||
assert.deepEqual(events, [["session_created", "s1"]]);
|
||||
});
|
||||
|
||||
it("names a session with no thread name by its first prompt", async () => {
|
||||
writeRollout("s2", [
|
||||
meta("s2"),
|
||||
{
|
||||
type: "event_msg",
|
||||
payload: { type: "user_message", message: " Fix the login bug\nthen ship" },
|
||||
},
|
||||
{ type: "event_msg", payload: { type: "user_message", message: "second prompt" } },
|
||||
ctx("gpt-a"),
|
||||
tc([1, 0, 1], [1, 0, 1]),
|
||||
]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(stmts.getSession.get("s2").name, "Fix the login bug");
|
||||
});
|
||||
|
||||
it("reads a prompt from the item_completed UserMessage shape too", async () => {
|
||||
writeRollout("s3", [
|
||||
meta("s3"),
|
||||
{
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "item_completed",
|
||||
item: { type: "UserMessage", content: [{ type: "text", text: "Continue the task" }] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(stmts.getSession.get("s3").name, "Continue the task");
|
||||
});
|
||||
|
||||
it("is active while a journal was written in the last two minutes", async () => {
|
||||
const now = Date.now();
|
||||
writeRollout(
|
||||
"s1",
|
||||
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
|
||||
new Date(now - 10_000)
|
||||
);
|
||||
await ingestCodexUsage({ now });
|
||||
assert.equal(stmts.getSession.get("s1").status, "active");
|
||||
assert.equal(stmts.getSession.get("s1").ended_at, null);
|
||||
});
|
||||
|
||||
it("settles an active session to completed once its journals go quiet", async () => {
|
||||
const now = Date.now();
|
||||
writeRollout(
|
||||
"s1",
|
||||
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
|
||||
new Date(now - 10_000)
|
||||
);
|
||||
await ingestCodexUsage({ now });
|
||||
const events = [];
|
||||
settleActive((t, d) => events.push([t, d.status]), now + 5 * 60_000);
|
||||
assert.equal(stmts.getSession.get("s1").status, "completed");
|
||||
assert.equal(
|
||||
stmts.getSession.get("s1").ended_at,
|
||||
new Date(Math.floor(now - 10_000)).toISOString()
|
||||
);
|
||||
assert.deepEqual(events, [["session_updated", "completed"]]);
|
||||
});
|
||||
|
||||
it("skips the history a fork copied from its parent", async () => {
|
||||
const parent = [
|
||||
meta("p1"),
|
||||
ctx("gpt-a"),
|
||||
tc([100, 0, 10], [100, 0, 10]),
|
||||
tc([300, 0, 30], [200, 0, 20]),
|
||||
];
|
||||
writeRollout("p1", parent);
|
||||
writeRollout("f1", [
|
||||
meta("f1", { forked_from_id: "p1" }),
|
||||
meta("p1"),
|
||||
...parent.slice(1),
|
||||
tc([350, 0, 35], [50, 0, 5]),
|
||||
]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.deepEqual(usage("p1"), [{ model: "gpt-a", i: 300, r: 0, o: 30 }]);
|
||||
assert.deepEqual(usage("f1"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
|
||||
});
|
||||
|
||||
it("folds a sub-agent thread into its root session", async () => {
|
||||
writeRollout("r1", [meta("r1"), ctx("gpt-a"), tc([100, 0, 10], [100, 0, 10])]);
|
||||
writeRollout("c1", [
|
||||
meta("c1", { session_id: "r1", thread_source: "subagent" }),
|
||||
ctx("gpt-b"),
|
||||
tc([40, 0, 4], [40, 0, 4]),
|
||||
]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(stmts.getSession.get("c1"), undefined);
|
||||
assert.deepEqual(usage("r1"), [
|
||||
{ model: "gpt-a", i: 100, r: 0, o: 10 },
|
||||
{ model: "gpt-b", i: 40, r: 0, o: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads only what was appended, and re-reads a file that shrank", async () => {
|
||||
const lines = [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])];
|
||||
const file = writeRollout("s1", lines);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
fs.appendFileSync(file, jsonl([tc([30, 0, 3], [20, 0, 2])]) + '{"partial":');
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
|
||||
|
||||
fs.writeFileSync(file, jsonl(lines)); // rewritten shorter
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }]);
|
||||
});
|
||||
|
||||
it("does not bring back a deleted session while its journal is unchanged", async () => {
|
||||
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
db.prepare("DELETE FROM sessions WHERE id = 's1'").run();
|
||||
db.prepare("DELETE FROM token_usage WHERE session_id = 's1'").run();
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(stmts.getSession.get("s1"), undefined);
|
||||
});
|
||||
|
||||
it("never touches a Claude session with the same id", async () => {
|
||||
stmts.insertSession.run("s1", "mine", "active", "/c", "claude-x", null);
|
||||
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
const row = stmts.getSession.get("s1");
|
||||
assert.equal(row.tool, "claude");
|
||||
assert.equal(row.name, "mine");
|
||||
assert.deepEqual(usage("s1"), []);
|
||||
});
|
||||
|
||||
it("keeps skipping a fork's copied history that arrives after the first read", async () => {
|
||||
const parent = [
|
||||
meta("p1"),
|
||||
ctx("gpt-a"),
|
||||
tc([100, 0, 10], [100, 0, 10]),
|
||||
tc([300, 0, 30], [200, 0, 20]),
|
||||
];
|
||||
writeRollout("p1", parent);
|
||||
const fork = writeRollout("f1", [meta("f1", { forked_from_id: "p1" }), meta("p1"), parent[1]]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
fs.appendFileSync(fork, jsonl([...parent.slice(2), tc([350, 0, 35], [50, 0, 5])]));
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.deepEqual(usage("f1"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
|
||||
});
|
||||
|
||||
it("counts a fork whole, and says so, when its parent's journal is gone", async () => {
|
||||
const warn = console.warn;
|
||||
const warned = [];
|
||||
console.warn = (m) => warned.push(String(m));
|
||||
try {
|
||||
writeRollout("f1", [
|
||||
meta("f1", { forked_from_id: "gone" }),
|
||||
ctx("gpt-a"),
|
||||
tc([100, 0, 10], [100, 0, 10]),
|
||||
]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
assert.deepEqual(usage("f1"), [{ model: "gpt-a", i: 100, r: 0, o: 10 }]);
|
||||
assert.ok(warned.some((m) => m.includes("fork parent gone")));
|
||||
});
|
||||
|
||||
it("does not count a journal twice when CODEX_HOME moves", async () => {
|
||||
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
const home = process.env.CODEX_HOME;
|
||||
fs.renameSync(home, `${home}-moved`);
|
||||
process.env.CODEX_HOME = `${home}-moved`;
|
||||
try {
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
} finally {
|
||||
fs.renameSync(`${home}-moved`, home);
|
||||
process.env.CODEX_HOME = home;
|
||||
}
|
||||
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }]);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM codex_rollouts").get().n, 1);
|
||||
});
|
||||
|
||||
it("does not call a journal written in the future active", async () => {
|
||||
const now = Date.now();
|
||||
writeRollout(
|
||||
"s1",
|
||||
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
|
||||
new Date(now + 60_000)
|
||||
);
|
||||
await ingestCodexUsage({ now });
|
||||
assert.equal(stmts.getSession.get("s1").status, "completed");
|
||||
});
|
||||
|
||||
it("drops the usage of a model a re-read journal no longer has", async () => {
|
||||
const file = writeRollout("s1", [
|
||||
meta("s1"),
|
||||
ctx("gpt-a"),
|
||||
tc([10, 0, 1], [10, 0, 1]),
|
||||
ctx("gpt-b"),
|
||||
tc([30, 0, 3], [20, 0, 2]),
|
||||
]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(usage("s1").length, 2);
|
||||
fs.writeFileSync(file, jsonl([meta("s1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]));
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
|
||||
});
|
||||
|
||||
it("takes cwd, start and model from the root thread even when a sub-agent is read first", async () => {
|
||||
writeRollout("c1", [
|
||||
meta("c1", { session_id: "r1", cwd: "/w/child", timestamp: "2026-09-29T01:30:00.000Z" }),
|
||||
ctx("gpt-b"),
|
||||
tc([40, 0, 4], [40, 0, 4]),
|
||||
]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(stmts.getSession.get("r1").cwd, "/w/child");
|
||||
writeRollout(
|
||||
"r1",
|
||||
[meta("r1"), ctx("gpt-a"), tc([100, 0, 10], [100, 0, 10])],
|
||||
new Date("2026-09-29T01:40:00Z")
|
||||
);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
const row = stmts.getSession.get("r1");
|
||||
assert.equal(row.cwd, "/w/p");
|
||||
assert.equal(row.started_at, "2026-09-29T01:00:00.000Z");
|
||||
assert.equal(row.model, "gpt-a"); // the root's, though the child's file is newer
|
||||
});
|
||||
|
||||
it("reads a journal with a line longer than one read in bounded steps", async () => {
|
||||
const file = writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||
fs.appendFileSync(file, `{"type":"response_item","x":"${"y".repeat(17 * 1024 * 1024)}"}\n`);
|
||||
fs.appendFileSync(file, jsonl([tc([30, 0, 3], [20, 0, 2])]));
|
||||
for (let i = 0; i < 4; i++) await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
|
||||
const row = db.prepare("SELECT offset, size FROM codex_rollouts").get();
|
||||
assert.equal(row.offset, row.size);
|
||||
});
|
||||
|
||||
it("leaves a hook-fed session's status to its hooks, and still writes its usage", async () => {
|
||||
stmts.insertSession.run("s1", "Codex s1", "active", "/w/p", null, null);
|
||||
db.prepare("UPDATE sessions SET tool = 'codex' WHERE id = 's1'").run();
|
||||
stmts.insertEvent.run("s1", null, "Stop", null, "ready", "{}");
|
||||
const before = stmts.getSession.get("s1");
|
||||
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
settleActive(() => {}, Date.parse("2026-09-29T09:00:00Z"));
|
||||
const row = stmts.getSession.get("s1");
|
||||
assert.equal(row.status, "active");
|
||||
assert.equal(row.ended_at, null);
|
||||
assert.equal(row.updated_at, before.updated_at);
|
||||
assert.equal(row.model, "gpt-a");
|
||||
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }]);
|
||||
});
|
||||
|
||||
it("hands the status back to the journal once the hooks stop reporting", async () => {
|
||||
// Hooked once (an hour before), then the hooks were removed; the journal
|
||||
// kept being written.
|
||||
stmts.insertSession.run("s1", "Codex s1", "abandoned", "/w/p", null, null);
|
||||
db.prepare("UPDATE sessions SET tool = 'codex' WHERE id = 's1'").run();
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, created_at) VALUES ('s1', 'Stop', '2026-09-29T01:00:00.000Z')"
|
||||
).run();
|
||||
const now = Date.parse("2026-09-29T02:00:30Z");
|
||||
writeRollout(
|
||||
"s1",
|
||||
[meta("s1"), ctx("gpt-a"), tc([30, 0, 3], [30, 0, 3])],
|
||||
new Date(now - 30_000)
|
||||
);
|
||||
await ingestCodexUsage({ now });
|
||||
assert.equal(stmts.getSession.get("s1").status, "active");
|
||||
settleActive(() => {}, now + 5 * 60_000);
|
||||
assert.equal(stmts.getSession.get("s1").status, "completed");
|
||||
});
|
||||
|
||||
it("imports its journals again after Clear data", async () => {
|
||||
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
const express = require("express");
|
||||
const http = require("node:http");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/settings", require("../routes/settings"));
|
||||
const server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${server.address().port}/api/settings/clear-data`, {
|
||||
method: "POST",
|
||||
});
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
assert.equal(stmts.getSession.get("s1"), undefined);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
|
||||
});
|
||||
|
||||
it("answers quietly when CODEX_HOME has no sessions", async () => {
|
||||
const r = await ingestCodexUsage({ now: Date.now() });
|
||||
assert.deepEqual(r, { files: 0, sessions: 0 });
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@
|
||||
* local server to process the buffered request on its own schedule. These tests
|
||||
* lock in that non-blocking contract so a future refactor can't reintroduce the
|
||||
* "stuck running hooks" stall (handler waiting up to the per-request timeout for
|
||||
* a slow/busy/wedged dashboard to reply).
|
||||
* a slow/busy/wedged dashboard to reply). Also: a Codex hook (second argument
|
||||
* `codex`) is marked `tool: "codex"`, an Antigravity one `tool: "antigravity"`,
|
||||
* and any other second argument is not passed on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -46,10 +48,10 @@ function startMockServer({ responseDelayMs }) {
|
||||
|
||||
// Spawn the real handler, pipe a hook payload to stdin, and time how long it
|
||||
// takes to exit.
|
||||
function runHandler({ port, hookType = "Stop", payload }) {
|
||||
function runHandler({ port, hookType = "Stop", payload, extra = [] }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = process.hrtime.bigint();
|
||||
const child = spawn(process.execPath, [HANDLER, hookType], {
|
||||
const child = spawn(process.execPath, [HANDLER, hookType, ...extra], {
|
||||
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(port) },
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
});
|
||||
@@ -90,6 +92,34 @@ describe("hook-handler non-blocking delivery", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("marks a Codex hook with tool: codex, and a Claude hook with no tool at all", async () => {
|
||||
const { server, port, received } = await startMockServer({ responseDelayMs: 0 });
|
||||
try {
|
||||
await runHandler({
|
||||
port,
|
||||
hookType: "PreToolUse",
|
||||
payload: { session_id: "cx" },
|
||||
extra: ["codex"],
|
||||
});
|
||||
await runHandler({ port, hookType: "PreToolUse", payload: { session_id: "cc" } });
|
||||
await runHandler({
|
||||
port,
|
||||
hookType: "Stop",
|
||||
payload: { session_id: "ag" },
|
||||
extra: ["antigravity"],
|
||||
});
|
||||
await runHandler({ port, hookType: "Stop", payload: { session_id: "zz" }, extra: ["evil"] });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const bodies = received.map((b) => JSON.parse(b));
|
||||
assert.equal(bodies.find((b) => b.data.session_id === "cx").tool, "codex");
|
||||
assert.equal(bodies.find((b) => b.data.session_id === "ag").tool, "antigravity");
|
||||
assert.equal("tool" in bodies.find((b) => b.data.session_id === "zz"), false);
|
||||
assert.equal("tool" in bodies.find((b) => b.data.session_id === "cc"), false);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("exits promptly when no dashboard is listening (connection refused)", async () => {
|
||||
// Grab a port then close it so nothing is listening there.
|
||||
const { server, port } = await startMockServer({ responseDelayMs: 0 });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Tests for the automatic pricing sync: a model seen in token_usage with
|
||||
* no row of its own gets one from the price list, an edited row is never
|
||||
* overwritten, and a bad list or a failed fetch changes nothing.
|
||||
* overwritten, a bad list or a failed fetch changes nothing, and an OpenAI
|
||||
* model (a Codex session's) is priced from its `openai` entry too.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -15,7 +16,14 @@ process.env.DASHBOARD_DB_PATH = path.join(
|
||||
`pricing-sync-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
const { db } = require("../db");
|
||||
const { syncPricing, familyOf, displayName, _resetCache } = require("../lib/pricing-sync");
|
||||
const {
|
||||
syncPricing,
|
||||
familyOf,
|
||||
displayName,
|
||||
toRow,
|
||||
_resetCache,
|
||||
_setCache,
|
||||
} = require("../lib/pricing-sync");
|
||||
|
||||
// The real LiteLLM entry for Opus 5.5, trimmed.
|
||||
const LIST = {
|
||||
@@ -43,15 +51,49 @@ const row = (p) => db.prepare("SELECT * FROM model_pricing WHERE model_pattern =
|
||||
beforeEach(() => {
|
||||
_resetCache();
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%'").run();
|
||||
db.prepare(
|
||||
"DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%' OR model_pattern LIKE 'gpt-9%'"
|
||||
).run();
|
||||
});
|
||||
|
||||
describe("pricing sync", () => {
|
||||
it("reduces an id to its family and names it", () => {
|
||||
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
|
||||
assert.equal(familyOf("gpt-4"), null);
|
||||
assert.equal(familyOf("gpt-5.6-sol"), "gpt-5.6-sol");
|
||||
assert.equal(familyOf("gpt-5.4-2026-03-05"), "gpt-5.4");
|
||||
assert.equal(familyOf("o3-mini"), "o3-mini");
|
||||
assert.equal(familyOf("clinepass/cline-pass/deepseek-v4-pro"), null);
|
||||
assert.equal(familyOf("Highest"), null);
|
||||
assert.equal(familyOf("claude-x%"), null);
|
||||
assert.equal(displayName("claude-opus-5-5"), "Claude Opus 5.5");
|
||||
assert.equal(displayName("gpt-5.6-sol"), "GPT-5.6 Sol");
|
||||
assert.equal(displayName("o3-mini"), "o3 Mini");
|
||||
});
|
||||
|
||||
it("prices a Gemini model from Vertex AI's entry, never another vendor's", async () => {
|
||||
assert.equal(familyOf("gemini-3.8-flash"), "gemini-3.8-flash");
|
||||
assert.equal(displayName("gemini-3.8-flash"), "Gemini 3.8 Flash");
|
||||
useModel("gemini-3.8-flash");
|
||||
const list = {
|
||||
"gemini-3.8-flash": {
|
||||
litellm_provider: "vertex_ai-language-models",
|
||||
input_cost_per_token: 7.5e-7,
|
||||
output_cost_per_token: 3.75e-6,
|
||||
cache_read_input_token_cost: 7.5e-8,
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), ["gemini-3.8-flash%"]);
|
||||
const r = row("gemini-3.8-flash%");
|
||||
assert.equal(r.display_name, "Gemini 3.8 Flash");
|
||||
assert.deepEqual(
|
||||
[r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok],
|
||||
[0.75, 3.75, 0.075]
|
||||
);
|
||||
assert.equal(r.cache_write_per_mtok, 0);
|
||||
const other = {
|
||||
"gemini-3.8-flash": { ...list["gemini-3.8-flash"], litellm_provider: "openrouter" },
|
||||
};
|
||||
assert.equal(toRow("gemini-3.8-flash", other["gemini-3.8-flash"]), null);
|
||||
});
|
||||
|
||||
it("adds a row for a used model that only a broader pattern matched", async () => {
|
||||
@@ -96,5 +138,113 @@ describe("pricing sync", () => {
|
||||
});
|
||||
assert.deepEqual(failed, []);
|
||||
assert.equal(row("claude-opus-9-9%"), undefined);
|
||||
// A failure is not retried by the next caller, only after the check interval.
|
||||
let calls = 0;
|
||||
const counting = async () => {
|
||||
calls += 1;
|
||||
return LIST;
|
||||
};
|
||||
await syncPricing(db, { fetchList: counting });
|
||||
assert.equal(calls, 0);
|
||||
await syncPricing(db, { fetchList: counting, now: Date.now() + 16 * 60_000 });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
it("prices an OpenAI model from its openai entry, with no cache-write charge", async () => {
|
||||
useModel("gpt-9.1-sol");
|
||||
const list = {
|
||||
"gpt-9.1-sol": {
|
||||
litellm_provider: "openai",
|
||||
input_cost_per_token: 0.000004,
|
||||
output_cost_per_token: 0.00002,
|
||||
cache_read_input_token_cost: 4e-7,
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), ["gpt-9.1-sol%"]);
|
||||
const r = row("gpt-9.1-sol%");
|
||||
assert.equal(r.display_name, "GPT-9.1 Sol");
|
||||
assert.deepEqual([r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok], [4, 20, 0.4]);
|
||||
assert.deepEqual([r.cache_write_per_mtok, r.cache_write_1h_per_mtok], [0, 0]);
|
||||
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [0, 0]);
|
||||
});
|
||||
|
||||
it("refuses an entry whose provider does not match the model's vendor", async () => {
|
||||
useModel("gpt-9.2");
|
||||
const list = {
|
||||
"gpt-9.2": {
|
||||
litellm_provider: "azure",
|
||||
input_cost_per_token: 1e-6,
|
||||
output_cost_per_token: 1e-6,
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), []);
|
||||
});
|
||||
|
||||
it("POST /api/settings/reset-pricing brings a synced model's row straight back", async () => {
|
||||
const express = require("express");
|
||||
const http = require("node:http");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/settings", require("../routes/settings"));
|
||||
const server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const saved = process.env.AGENTHUB_PRICING_SYNC;
|
||||
process.env.AGENTHUB_PRICING_SYNC = "1";
|
||||
try {
|
||||
useModel("gpt-9.3");
|
||||
_setCache({
|
||||
"gpt-9.3": {
|
||||
litellm_provider: "openai",
|
||||
input_cost_per_token: 1e-6,
|
||||
output_cost_per_token: 8e-6,
|
||||
},
|
||||
});
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}/api/settings/reset-pricing`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
const body = await res.json();
|
||||
assert.ok(body.pricing.some((p) => p.model_pattern === "gpt-9.3%"));
|
||||
assert.ok(body.pricing.some((p) => p.model_pattern.startsWith("claude-")));
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.AGENTHUB_PRICING_SYNC;
|
||||
else process.env.AGENTHUB_PRICING_SYNC = saved;
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/settings/reset-pricing with scope claude keeps every other row", async () => {
|
||||
const express = require("express");
|
||||
const http = require("node:http");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/settings", require("../routes/settings"));
|
||||
const server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
try {
|
||||
const add = db.prepare(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
add.run("glm-4%", "GLM 4 (router)", 1, 2);
|
||||
add.run("claude-opus-5-5%", "edited", 999, 999);
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}/api/settings/reset-pricing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scope: "claude" }),
|
||||
}
|
||||
);
|
||||
const { pricing } = await res.json();
|
||||
assert.equal(pricing.find((p) => p.model_pattern === "glm-4%")?.input_per_mtok, 1);
|
||||
assert.notEqual(
|
||||
pricing.find((p) => p.model_pattern === "claude-opus-5-5%")?.input_per_mtok,
|
||||
999
|
||||
);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,6 +398,23 @@ describe("pty-run", () => {
|
||||
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
||||
});
|
||||
|
||||
it("hands a codex pane the dashboard's own CODEX_HOME, and a claude pane none", (t) => {
|
||||
const saved = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = "/tmp/custom-codex-home";
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = saved;
|
||||
});
|
||||
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
|
||||
const codex = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 40, cwd: "/tmp/repo", tool: "codex" })
|
||||
);
|
||||
assert.ok(envFlags(codex).includes("CODEX_HOME=/tmp/custom-codex-home"));
|
||||
tmux.__reset();
|
||||
const claude = captureNewSession(() => pty.spawnRun({ laneId: 41, cwd: "/tmp/repo" }));
|
||||
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
|
||||
});
|
||||
|
||||
describe("catalog tools", () => {
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const BIN = path.join(TMP, "bin");
|
||||
|
||||
@@ -351,6 +351,20 @@ describe("watchdog liveness reap", () => {
|
||||
assert.equal(stmts.getSession.get(sid).status, "active", "remote session must not be reaped");
|
||||
});
|
||||
|
||||
it("spares a Codex session, whose liveness codex-usage.js owns", async () => {
|
||||
// A `claude` process probe proves nothing about a Codex session: it is
|
||||
// settled from its own rollout journals' mtime instead.
|
||||
const sid = "cdx00000-0000-0000-0000-00000000000f";
|
||||
const tpath = await seedSession(sid, "/tmp/liveness-codex");
|
||||
db.prepare("UPDATE sessions SET tool = 'codex' WHERE id = ?").run(sid);
|
||||
backdate(sid, tpath);
|
||||
|
||||
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
|
||||
hooksRouter.livenessReap();
|
||||
|
||||
assert.equal(stmts.getSession.get(sid).status, "active", "codex session must not be reaped");
|
||||
});
|
||||
|
||||
it("does not touch error sessions", async () => {
|
||||
const sid = "errr0000-0000-0000-0000-000000000006";
|
||||
const cwd = "/tmp/liveness-error";
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @file Tests GET/PUT /api/settings/codex-home and setCodexHome: the shared
|
||||
* home-dir guard, persistence to the settings file, the `env_owned` flag, and
|
||||
* the AI Router layer being re-written into the NEW home for every codex lane
|
||||
* on a router (a `codex -p <name>` against a home without `<name>.config.toml`
|
||||
* would fail at start); a home change from another site refused, and a
|
||||
* CLAUDE_HOME change naming the lanes whose router profile the new home's
|
||||
* store lacks. Everything lives in 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 http = require("node:http");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-home-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.CODEX_HOME = path.join(ROOT, "codex-a");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const express = require("express");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { applyRouterToLane } = require("../lib/router-apply");
|
||||
const { envOwned } = require("../lib/settings-env");
|
||||
const settingsRouter = require("../routes/settings");
|
||||
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function request(method, url, body, extra = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
||||
const req = http.request(
|
||||
`${base}${url}`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
...(data ? { "content-type": "application/json", "content-length": data.length } : {}),
|
||||
...extra,
|
||||
},
|
||||
},
|
||||
(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 (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/settings", settingsRouter);
|
||||
server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("/api/settings/codex-home", () => {
|
||||
it("GET answers the current home and whether the environment owns it", async () => {
|
||||
const res = await request("GET", "/api/settings/codex-home");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.codex_home, process.env.CODEX_HOME);
|
||||
assert.equal(res.body.env_owned, envOwned.has("CODEX_HOME"));
|
||||
});
|
||||
|
||||
it("refuses a path that contains the user's home, changing nothing", async () => {
|
||||
const before = process.env.CODEX_HOME;
|
||||
const res = await request("PUT", "/api/settings/codex-home", { path: "/" });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error.code, "EBADHOME");
|
||||
assert.equal(process.env.CODEX_HOME, before);
|
||||
assert.ok(!fs.existsSync(process.env.AGENTHUB_SETTINGS_FILE));
|
||||
});
|
||||
|
||||
it("refuses a missing path and a non-string body", async () => {
|
||||
for (const body of [{ path: path.join(ROOT, "nope") }, { path: 7 }, {}]) {
|
||||
const res = await request("PUT", "/api/settings/codex-home", body);
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error.code, "INVALID_PATH");
|
||||
}
|
||||
});
|
||||
|
||||
it("switches, persists, and re-writes every codex lane's router layer into the new home", async () => {
|
||||
const cwd = path.join(ROOT, "lane");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const { name } = routers.createRouter({
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "Highest",
|
||||
});
|
||||
const lane = lanesLib.createLane({ title: "c", cwd, tool: "codex", router_profile: name });
|
||||
applyRouterToLane(lane, name);
|
||||
assert.ok(fs.existsSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`)));
|
||||
|
||||
const next = path.join(ROOT, "codex-b");
|
||||
fs.mkdirSync(next);
|
||||
const res = await request("PUT", "/api/settings/codex-home", { path: next });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.codex_home, next);
|
||||
assert.deepEqual(res.body.routers, { updated: 1, failed: [] });
|
||||
assert.equal(process.env.CODEX_HOME, next);
|
||||
assert.ok(fs.existsSync(path.join(next, `${name}.config.toml`)));
|
||||
assert.match(
|
||||
fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"),
|
||||
new RegExp(`^CODEX_HOME=${next}$`, "m")
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a home change asked by another site", async () => {
|
||||
const next = path.join(ROOT, "codex-evil");
|
||||
fs.mkdirSync(next);
|
||||
const res = await request(
|
||||
"PUT",
|
||||
"/api/settings/codex-home",
|
||||
{ path: next },
|
||||
{ Origin: "https://evil.example" }
|
||||
);
|
||||
assert.equal(res.status, 403);
|
||||
assert.notEqual(process.env.CODEX_HOME, next);
|
||||
});
|
||||
|
||||
it("a CLAUDE_HOME change names the lanes whose router the new store lacks", async () => {
|
||||
const next = path.join(ROOT, "claude-b");
|
||||
fs.mkdirSync(next);
|
||||
const res = await request("PUT", "/api/settings/claude-home", { path: next });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.routers.missing.length, 1);
|
||||
assert.match(res.body.routers.missing[0], /\(c\): 9router/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @file Tests server/lib/settings-env.js — where a setting saved from the
|
||||
* dashboard persists (a file outside the repo and outside CLAUDE_HOME, so a
|
||||
* plugin update cannot wipe it), the startup load order (real env, then the
|
||||
* checkout's `.env`, both owned, then the settings file), a save replacing
|
||||
* every earlier line for its key and writing through a symlink, and the one
|
||||
* path check both editable homes share. Every file lives in 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 { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-settings-env-"));
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const envSettings = require("../lib/settings-env");
|
||||
|
||||
describe("settings-env", () => {
|
||||
let n = 0;
|
||||
let file;
|
||||
let repoEnv;
|
||||
let env;
|
||||
beforeEach(() => {
|
||||
n += 1;
|
||||
file = path.join(ROOT, `cfg${n}`, "agenthub", "settings.env");
|
||||
repoEnv = path.join(ROOT, `repo${n}.env`);
|
||||
env = {};
|
||||
});
|
||||
|
||||
it("resolves its file from AGENTHUB_SETTINGS_FILE, else XDG_CONFIG_HOME, else ~/.config", () => {
|
||||
assert.equal(envSettings.settingsFilePath({ AGENTHUB_SETTINGS_FILE: "/x/s.env" }), "/x/s.env");
|
||||
assert.equal(
|
||||
envSettings.settingsFilePath({ XDG_CONFIG_HOME: "/cfg" }),
|
||||
path.join("/cfg", "agenthub", "settings.env")
|
||||
);
|
||||
assert.equal(
|
||||
envSettings.settingsFilePath({}),
|
||||
path.join(os.homedir(), ".config", "agenthub", "settings.env")
|
||||
);
|
||||
});
|
||||
|
||||
it("writes one line per key, replacing in place, with no blank-line growth, at 0600", () => {
|
||||
envSettings.writeSetting("CLAUDE_HOME", "/a", file);
|
||||
envSettings.writeSetting("CODEX_HOME", "/b", file);
|
||||
envSettings.writeSetting("CLAUDE_HOME", "/c", file);
|
||||
envSettings.writeSetting("CLAUDE_HOME", "/d", file);
|
||||
assert.equal(fs.readFileSync(file, "utf8"), "CLAUDE_HOME=/d\nCODEX_HOME=/b\n");
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700);
|
||||
});
|
||||
|
||||
it("loads the real env first, then the checkout's .env (owned), then the settings file", () => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
"CLAUDE_HOME=/from-settings\nCODEX_HOME=~/codex\nDASHBOARD_DATA_DIR=/live\n# note\n"
|
||||
);
|
||||
fs.writeFileSync(repoEnv, 'DASHBOARD_DATA_DIR=/dev-data\nOTHER="quoted"\nCODEX_HOME=/repo\n');
|
||||
env.CODEX_HOME = "/from-env";
|
||||
const owned = envSettings.loadEnvFiles({ env, owned: [repoEnv], files: [file] });
|
||||
assert.equal(env.CODEX_HOME, "/from-env");
|
||||
// A dev checkout's own data dir wins over the one the dashboard pinned.
|
||||
assert.equal(env.DASHBOARD_DATA_DIR, "/dev-data");
|
||||
assert.equal(env.CLAUDE_HOME, "/from-settings");
|
||||
assert.equal(env.OTHER, "quoted");
|
||||
assert.ok(owned.has("CODEX_HOME"));
|
||||
assert.ok(owned.has("DASHBOARD_DATA_DIR"), "a .env key is the environment's");
|
||||
assert.ok(!owned.has("CLAUDE_HOME"));
|
||||
assert.equal(env.AGENTHUB_FILE_KEYS, "CLAUDE_HOME");
|
||||
});
|
||||
|
||||
it("reads the settings file a .env names in AGENTHUB_SETTINGS_FILE", () => {
|
||||
const other = path.join(ROOT, "other", "settings.env");
|
||||
fs.mkdirSync(path.dirname(other), { recursive: true });
|
||||
fs.writeFileSync(other, "CLAUDE_HOME=/from-other\n");
|
||||
fs.writeFileSync(repoEnv, `AGENTHUB_SETTINGS_FILE=${other}\n`);
|
||||
delete env.AGENTHUB_SETTINGS_FILE;
|
||||
envSettings.loadEnvFiles({ env, owned: [repoEnv] });
|
||||
assert.equal(env.CLAUDE_HOME, "/from-other");
|
||||
});
|
||||
|
||||
it("replaces a hand-written `KEY = value` line, and writes through a symlink", () => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, "# mine\nCODEX_HOME = /old\nCODEX_HOME=/older\nX=1\n");
|
||||
envSettings.writeSetting("CODEX_HOME", "/new", file);
|
||||
assert.equal(fs.readFileSync(file, "utf8"), "# mine\nCODEX_HOME=/new\nX=1\n");
|
||||
const real = path.join(ROOT, `dotfiles${n}.env`);
|
||||
fs.renameSync(file, real);
|
||||
fs.symlinkSync(real, file);
|
||||
envSettings.writeSetting("CODEX_HOME", "/newer", file);
|
||||
assert.ok(fs.lstatSync(file).isSymbolicLink());
|
||||
assert.match(fs.readFileSync(real, "utf8"), /CODEX_HOME=\/newer/);
|
||||
});
|
||||
|
||||
it("expands ~/ in a loaded value and skips missing files", () => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, "CODEX_HOME=~/codex\n");
|
||||
envSettings.loadEnvFiles({ env, files: [path.join(ROOT, "missing.env"), file] });
|
||||
assert.equal(env.CODEX_HOME, path.join(os.homedir(), "codex"));
|
||||
});
|
||||
|
||||
it("names the file-loaded keys, and a successor inheriting them re-reads the file", () => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, "CLAUDE_HOME=/saved\n");
|
||||
env.CODEX_HOME = "/from-env";
|
||||
envSettings.loadEnvFiles({ env, files: [file] });
|
||||
assert.equal(env.AGENTHUB_FILE_KEYS, "CLAUDE_HOME");
|
||||
// A self-restart hands this env on; the file changed meanwhile.
|
||||
fs.writeFileSync(file, "CLAUDE_HOME=/saved-later\n");
|
||||
const owned = envSettings.loadEnvFiles({ env, files: [file] });
|
||||
assert.ok(!owned.has("CLAUDE_HOME"), "a file-loaded key is not the real env's");
|
||||
assert.ok(owned.has("CODEX_HOME"));
|
||||
assert.equal(env.CLAUDE_HOME, "/saved-later");
|
||||
});
|
||||
|
||||
it("refuses a value with a line break, which would inject a second key", () => {
|
||||
assert.throws(
|
||||
() => envSettings.writeSetting("CLAUDE_HOME", "/a\nNODE_OPTIONS=x", file),
|
||||
/line break/
|
||||
);
|
||||
assert.ok(!fs.existsSync(file));
|
||||
});
|
||||
|
||||
describe("validateHomeDir", () => {
|
||||
const dir = () => fs.mkdtempSync(path.join(ROOT, "home-"));
|
||||
|
||||
for (const bad of ["/", "/.", os.homedir(), `${os.homedir()}/`, "~/"]) {
|
||||
it(`refuses ${JSON.stringify(bad)} as too broad`, () => {
|
||||
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", bad), { code: "EBADHOME" });
|
||||
});
|
||||
}
|
||||
|
||||
it("refuses a parent of the home dir, and a symlink resolving to one", () => {
|
||||
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", path.dirname(os.homedir())), {
|
||||
code: "EBADHOME",
|
||||
});
|
||||
const link = path.join(ROOT, "link-to-root");
|
||||
fs.symlinkSync("/", link);
|
||||
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", link), { code: "EBADHOME" });
|
||||
const homeLink = path.join(ROOT, "link-to-home");
|
||||
fs.symlinkSync(os.homedir(), homeLink);
|
||||
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", homeLink), {
|
||||
code: "EBADHOME",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a relative path, a missing path and a file", () => {
|
||||
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", "rel"), /absolute path/);
|
||||
assert.throws(
|
||||
() => envSettings.validateHomeDir("CODEX_HOME", path.join(ROOT, "nope")),
|
||||
/does not exist/
|
||||
);
|
||||
const f = path.join(ROOT, "a-file");
|
||||
fs.writeFileSync(f, "");
|
||||
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", f), /Not a directory/);
|
||||
});
|
||||
|
||||
it("returns the normalized absolute path, trimmed", () => {
|
||||
const d = dir();
|
||||
assert.equal(envSettings.validateHomeDir("CODEX_HOME", ` ${d}/./ `), d);
|
||||
});
|
||||
|
||||
it("refuses control characters with INVALID_PATH", () => {
|
||||
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", `${dir()}\nX=1`), {
|
||||
code: "INVALID_PATH",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user