/** * @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 `/.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ĩ */ 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} 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, };