Files
AgentHub/server/__tests__/settings-codex-home.test.js
T
nntrivi2001 969b06253c 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.
2026-09-29 16:09:19 +07:00

149 lines
5.5 KiB
JavaScript

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