969b06253c
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.
174 lines
7.4 KiB
JavaScript
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",
|
|
});
|
|
});
|
|
});
|
|
});
|