Files
AgentHub/server/__tests__/settings-env.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

174 lines
7.4 KiB
JavaScript

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