/** * @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ĩ */ 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", }); }); }); });