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

200 lines
7.5 KiB
JavaScript

/**
* @file Where a setting saved from the dashboard (CLAUDE_HOME, CODEX_HOME, the
* pinned DASHBOARD_DATA_DIR) persists, and the startup env load order.
*
* The file is `$XDG_CONFIG_HOME/agenthub/settings.env` (default
* `~/.config/agenthub/`, or `AGENTHUB_SETTINGS_FILE`) — outside the repo,
* because a plugin install runs from the plugin cache Claude Code replaces on
* every update, and outside CLAUDE_HOME, because the data dir is derived from
* CLAUDE_HOME and the setting that moves it cannot live under it.
*
* Load order: the real environment wins, then a checkout's own `<repo>/.env`
* (per checkout, so more specific than a per-user file: a dev checkout whose
* `.env` names its own DASHBOARD_DATA_DIR never opens the live database), then
* this file; a key is only ever set while still unset, and a key the real
* environment or `.env` set is `env_owned` — a value saved from the dashboard
* would be overridden again at the next start, and the UI says so. The keys
* that came from a file are listed in `AGENTHUB_FILE_KEYS`, so a server that
* inherits this process's env (a self-restart spawns its successor with it)
* does not mistake them for the real environment's and re-reads them. Deliberately dependency-free —
* `server/index.js` runs it before anything else is required.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
/** Keys the real environment supplied at startup; filled by `loadEnvFiles`. */
const envOwned = new Set();
function settingsFilePath(env = process.env) {
if (env.AGENTHUB_SETTINGS_FILE) return env.AGENTHUB_SETTINGS_FILE;
const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
return path.join(base, "agenthub", "settings.env");
}
function expandHome(value) {
return value.replace(/^~(?=\/|$)/, os.homedir());
}
function parseEnv(text) {
const out = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let val = trimmed.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
out.push([key, val]);
}
return out;
}
/**
* Fill `env` from `owned` then `files`, in order, never overwriting a key
* already set. A key from an `owned` file (the checkout's `.env`) counts as
* the environment's, like one the real environment held. `files` defaults to
* the settings file, located AFTER the owned files load, so an
* `AGENTHUB_SETTINGS_FILE` set in `.env` is the file read and written.
* @returns {Set<string>} the owned keys (inherited file-loaded keys, named in
* `AGENTHUB_FILE_KEYS`, excluded).
*/
function loadEnvFiles({ env = process.env, owned = [], files } = {}) {
for (const k of (env.AGENTHUB_FILE_KEYS || "").split(",")) if (k) delete env[k];
delete env.AGENTHUB_FILE_KEYS;
const before = new Set(Object.keys(env).filter((k) => env[k]));
const loaded = new Set();
const load = (file, into) => {
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
return;
}
for (const [key, val] of parseEnv(text)) {
if (!env[key]) {
env[key] = expandHome(val);
into.add(key);
}
}
};
for (const file of owned) load(file, before);
for (const file of files ?? [settingsFilePath(env)]) load(file, loaded);
if (loaded.size) env.AGENTHUB_FILE_KEYS = [...loaded].join(",");
if (env === process.env) for (const k of before) envOwned.add(k);
return before;
}
/**
* Persist a setting and apply it to this process. A key the real environment
* does not own joins `AGENTHUB_FILE_KEYS`, like one loaded from the file.
*/
function applySetting(key, value) {
writeSetting(key, value);
process.env[key] = value;
if (envOwned.has(key)) return;
const keys = new Set((process.env.AGENTHUB_FILE_KEYS || "").split(",").filter(Boolean));
keys.add(key);
process.env.AGENTHUB_FILE_KEYS = [...keys].join(",");
}
/**
* Set or replace one `KEY=value` line — every earlier line for that key,
* `KEY = value` included, is dropped, or the first one would still win at the
* next load. Atomic, dir 0700, file 0600, through a symlink to its target.
*/
// ponytail: read-modify-write without a lock — two servers saving at the same
// instant can drop one key; add a lock file if that ever happens for real.
function writeSetting(key, value, file = settingsFilePath()) {
if (/[\r\n]/.test(value)) throw new Error(`${key} may not contain a line break`);
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
let lines = [];
try {
lines = fs
.readFileSync(file, "utf8")
.split("\n")
.filter((l) => l.trim() !== "");
} catch {
/* first write */
}
const isKey = (l) => {
const t = l.trim();
return !t.startsWith("#") && t.includes("=") && t.slice(0, t.indexOf("=")).trim() === key;
};
let done = false;
lines = lines.flatMap((l) =>
!isKey(l) ? [l] : done ? [] : ((done = true), [`${key}=${value}`])
);
if (!done) lines.push(`${key}=${value}`);
let target = file;
try {
target = fs.realpathSync(file);
} catch {
/* first write */
}
const tmp = `${target}.${process.pid}.tmp`;
fs.writeFileSync(tmp, `${lines.join("\n")}\n`, { mode: 0o600 });
fs.renameSync(tmp, target);
}
/**
* The one check an editable home directory passes. A directory that IS, or
* CONTAINS, the OS user's home — `/`, `/home`, `~` itself, or a symlink, a
* bind mount or (on a case-insensitive disk) a differently-cased path to any of
* them — is refused (`EBADHOME`): everything under such a home becomes
* readable, and partly writable, through its config panel. Compared by device
* and inode against the home and each of its ancestors.
* @returns {string} the normalized absolute path (not symlink-resolved).
*/
function validateHomeDir(label, input) {
const invalid = (message) => Object.assign(new Error(message), { code: "INVALID_PATH" });
const trimmed = String(input).trim();
if (/[\x00-\x1f]/.test(trimmed)) throw invalid(`${label} may not contain control characters`);
const expanded = expandHome(trimmed);
if (!path.isAbsolute(expanded)) throw invalid(`${label} must be an absolute path`);
const resolved = path.resolve(expanded);
const tooBroad = () =>
Object.assign(new Error(`Too broad for ${label}: ${resolved} contains your home directory`), {
code: "EBADHOME",
});
const contains = (dir, home) =>
home === dir || home.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
if (contains(resolved, path.resolve(os.homedir()))) throw tooBroad();
if (!fs.existsSync(resolved)) throw invalid(`Directory does not exist: ${resolved}`);
if (!fs.statSync(resolved).isDirectory()) throw invalid(`Not a directory: ${resolved}`);
let realHome;
try {
realHome = fs.realpathSync(os.homedir());
} catch {
realHome = path.resolve(os.homedir());
}
const idOf = (p) => {
try {
const st = fs.statSync(p);
return `${st.dev}:${st.ino}`;
} catch {
return null;
}
};
const target = idOf(resolved);
for (let dir = realHome; ; dir = path.dirname(dir)) {
if (idOf(dir) === target) throw tooBroad();
if (dir === path.dirname(dir)) break;
}
return resolved;
}
module.exports = {
settingsFilePath,
loadEnvFiles,
writeSetting,
applySetting,
validateHomeDir,
envOwned,
};