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,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