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.
200 lines
7.5 KiB
JavaScript
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,
|
|
};
|