/** * @file Tests for attaching an AI Router profile to a lane — the Claude Code * `settings.local.json` env merge and the Codex `.config.toml` layer, * including the guarantees that neither write destroys anything already there. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); const { execFileSync } = require("node:child_process"); const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-")); process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude"); process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex"); process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); const { describe, it, beforeEach, after } = require("node:test"); const assert = require("node:assert/strict"); const routers = require("../lib/ai-routers"); const lanesLib = require("../lib/lanes"); const { applyRouterToLane, reapplyToLanes, detachFromLanes, routerEnvForSpawn, routerArgvForSpawn, } = require("../lib/router-apply"); const LANE_CWD = path.join(SUITE_ROOT, "project"); const SETTINGS = path.join(LANE_CWD, ".claude", "settings.local.json"); after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); beforeEach(() => { fs.rmSync(path.join(process.env.CLAUDE_HOME, "agenthub-routers"), { recursive: true, force: true, }); fs.rmSync(LANE_CWD, { recursive: true, force: true }); fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true }); fs.mkdirSync(LANE_CWD, { recursive: true }); // The DB outlives each test; a profile name is reused, so lanes left behind // would be picked up by the next test's `reapplyToLanes` and inflate its count. for (const lane of lanesLib.listLanes()) lanesLib.deleteLane(lane.id); }); function saveNineRouter() { return routers.createRouter({ label: "9Router", baseUrl: "http://localhost:20128/v1", apiKey: "sk-secret-value", model: "Highest", }); } const claudeLane = () => ({ id: 7, cwd: LANE_CWD, tool: "claude" }); const codexLane = () => ({ id: 8, cwd: LANE_CWD, tool: "codex" }); describe("router-apply — Claude lane", () => { it("writes the eight env keys the reference settings file swaps", () => { const { name } = saveNineRouter(); applyRouterToLane(claudeLane(), name); const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env; assert.equal(env.ANTHROPIC_BASE_URL, "http://localhost:20128/v1"); assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-secret-value"); assert.equal(env.ANTHROPIC_MODEL, "Highest"); assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Highest"); assert.equal(env.ANTHROPIC_DEFAULT_SONNET_MODEL, "Highest"); assert.equal(env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "Highest"); assert.equal(env.ANTHROPIC_SMALL_FAST_MODEL, "Highest"); assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, "Highest"); }); it("keeps every unrelated key in the file it merges into", () => { // A lane's settings.local.json is the user's file; this feature owns eight // keys in it and nothing else. fs.mkdirSync(path.dirname(SETTINGS), { recursive: true }); fs.writeFileSync( SETTINGS, JSON.stringify({ permissions: { defaultMode: "bypassPermissions" }, env: { MY_OWN_VAR: "keep me", ANTHROPIC_MODEL: "to be replaced" }, }) ); const { name } = saveNineRouter(); applyRouterToLane(claudeLane(), name); const saved = JSON.parse(fs.readFileSync(SETTINGS, "utf8")); assert.equal(saved.permissions.defaultMode, "bypassPermissions"); assert.equal(saved.env.MY_OWN_VAR, "keep me"); assert.equal(saved.env.ANTHROPIC_MODEL, "Highest"); }); it("writes the file 0600, because it now holds an API key", () => { const { name } = saveNineRouter(); applyRouterToLane(claudeLane(), name); assert.equal(fs.statSync(SETTINGS).mode & 0o777, 0o600); }); it("blanks the connection variables for the native profile instead of dropping them", () => { // Absent keys would let a user-level router base URL apply to a lane that // explicitly asked for native. const { name } = saveNineRouter(); applyRouterToLane(claudeLane(), name); applyRouterToLane(claudeLane(), "native"); const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env; assert.equal(env.ANTHROPIC_BASE_URL, ""); assert.equal(env.ANTHROPIC_AUTH_TOKEN, ""); }); it("removes the model names for the native profile rather than pinning them", () => { // The opposite of the connection pair above, and for the opposite reason: // a project-scope model name overrides the user's own global choice, so a // lane on native must leave that choice alone instead of writing whatever // `claude-*` names this codebase happened to ship with. fs.mkdirSync(path.dirname(SETTINGS), { recursive: true }); fs.writeFileSync(SETTINGS, JSON.stringify({ env: { MY_OWN_VAR: "keep me" } })); const { name } = saveNineRouter(); applyRouterToLane(claudeLane(), name); applyRouterToLane(claudeLane(), "native"); const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env; assert.equal(env.ANTHROPIC_MODEL, undefined); assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, undefined); assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, undefined); assert.equal(env.MY_OWN_VAR, "keep me"); // clearing ours touches nothing else }); it("refuses a profile that does not exist rather than writing a broken file", () => { assert.throws(() => applyRouterToLane(claudeLane(), "ghost"), { code: "ENOTFOUND" }); assert.equal(fs.existsSync(SETTINGS), false); }); it("leaves a settings file that is not valid JSON alone", () => { fs.mkdirSync(path.dirname(SETTINGS), { recursive: true }); fs.writeFileSync(SETTINGS, "{not json"); const { name } = saveNineRouter(); assert.throws(() => applyRouterToLane(claudeLane(), name), { code: "EBADJSON" }); assert.equal(fs.readFileSync(SETTINGS, "utf8"), "{not json"); }); }); describe("router-apply — the written file must survive a lane reset", () => { // Real repos, asserted through `git status`: a fake `.git` dir let a test pass // while git itself never read the exclude file it checked. const REPO = path.join(SUITE_ROOT, "repo"); // A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which // would point these fixture calls at the outer repo. const git = (cwd, ...args) => { const env = { ...process.env }; for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) { delete env[name]; } return execFileSync("git", args, { cwd, env, encoding: "utf8" }); }; const untracked = (cwd) => git(cwd, "status", "--porcelain", "--untracked-files=all", "--ignored=no") .split("\n") .filter(Boolean); // A machine-wide ignore (Claude Code adds one for settings.local.json) would // hide the very file these tests look for. beforeEach(() => { process.env.GIT_CONFIG_GLOBAL = "/dev/null"; process.env.GIT_CONFIG_NOSYSTEM = "1"; fs.rmSync(REPO, { recursive: true, force: true }); fs.mkdirSync(REPO, { recursive: true }); git(REPO, "init", "-q"); git( REPO, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init" ); }); const laneAt = (cwd) => ({ id: 7, cwd, tool: "claude" }); it("hides settings.local.json and its backups, so `git clean -fd` cannot delete them", () => { // `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js). // Without -x that spares IGNORED files only — an untracked settings file // would be deleted, silently detaching the lane from its router. The same // entry also stops an API key from being committable. const { name } = saveNineRouter(); applyRouterToLane(laneAt(REPO), name); applyRouterToLane(laneAt(REPO), "native"); // a second write leaves a backup assert.deepEqual(untracked(REPO), []); }); it("hides them in a worktree lane, whose exclude file is the COMMON dir's", () => { // git reads info/exclude from the common git dir only; a copy under // .git/worktrees//info/ is never consulted. const wt = path.join(SUITE_ROOT, "wt"); fs.rmSync(wt, { recursive: true, force: true }); git(REPO, "worktree", "add", "-q", "-b", "lane1", wt); const { name } = saveNineRouter(); applyRouterToLane(laneAt(wt), name); applyRouterToLane(laneAt(wt), "native"); assert.deepEqual(untracked(wt), []); }); it("hides them in a lane that is a subdirectory of its repo", () => { const sub = path.join(REPO, "packages", "app"); fs.mkdirSync(sub, { recursive: true }); const { name } = saveNineRouter(); applyRouterToLane(laneAt(sub), name); applyRouterToLane(laneAt(sub), "native"); assert.deepEqual(untracked(REPO), []); }); it("adds the entry once, however many times a router is applied", () => { const { name } = saveNineRouter(); applyRouterToLane(laneAt(REPO), name); applyRouterToLane(laneAt(REPO), "native"); const exclude = fs.readFileSync(path.join(REPO, ".git", "info", "exclude"), "utf8"); assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1); }); it("still writes the settings file when the lane is not a git repo at all", () => { const { name } = saveNineRouter(); applyRouterToLane(claudeLane(), name); assert.ok(fs.existsSync(SETTINGS)); }); }); describe("router-apply — Antigravity lane", () => { const agyLane = () => ({ id: 9, cwd: LANE_CWD, tool: "antigravity" }); it("accepts native and writes nothing", () => { assert.equal(applyRouterToLane(agyLane(), "native").file, null); assert.equal(fs.existsSync(SETTINGS), false); }); it("refuses a real router instead of writing Claude's settings file", () => { const { name } = saveNineRouter(); assert.throws( () => applyRouterToLane(agyLane(), name), (err) => err.code === "EUNSUPPORTED" ); assert.equal(fs.existsSync(SETTINGS), false); }); }); describe("router-apply — Codex lane", () => { it("writes a profile layer file, never the base config", () => { const base = path.join(process.env.CODEX_HOME, "config.toml"); fs.mkdirSync(process.env.CODEX_HOME, { recursive: true }); fs.writeFileSync(base, 'approval_policy = "never"\n'); const { name } = saveNineRouter(); applyRouterToLane(codexLane(), name); assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n'); const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8"); assert.match(layer, /model_provider = "9router"/); assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/); assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/); }); it("keeps the key out of the config file, naming an env var instead", () => { // Codex reads the key from the environment; writing it into config.toml // would put a secret in a file with no reason to hold one. const { name } = saveNineRouter(); applyRouterToLane(codexLane(), name); const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8"); assert.ok(!layer.includes("sk-secret-value")); }); it("writes no layer file for the native profile", () => { // Native means "the base config as it already is" — a layer that overrode // nothing would still shadow a model the user set in the base. applyRouterToLane(codexLane(), "native"); assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, "native.config.toml")), false); }); it("does not touch the lane's .claude directory", () => { const { name } = saveNineRouter(); applyRouterToLane(codexLane(), name); assert.equal(fs.existsSync(SETTINGS), false); }); it('never writes wire_api = "chat", which codex refuses to load at all', () => { // Verified against the installed codex-cli 0.149.0: `wire_api = "chat" is // no longer supported` is a hard config error, so a layer carrying it makes // the lane fail to start rather than fall back to anything. const { name } = saveNineRouter(); applyRouterToLane(codexLane(), name); const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8"); assert.match(layer, /wire_api = "responses"/); assert.ok(!layer.includes('wire_api = "chat"')); }); it("names no env_key when the profile has no key to put in it", () => { // A local router needs no key — the reference 9Router provider has none. // Naming a variable that is never set makes codex refuse to start. const { name } = routers.createRouter({ label: "Local gateway", baseUrl: "http://127.0.0.1:20128/v1", apiKey: "", model: "Highest", }); applyRouterToLane(codexLane(), name); const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8"); assert.ok(!layer.includes("env_key"), layer); }); }); describe("router-apply — an edit or a delete reaches the lanes already using it", () => { // Attaching COPIES the profile into the lane's own config, so a store-only // write is inert exactly where it matters: the key a lane authenticates with. /** A real lane row in a directory of its own, already attached to `profile`. */ function laneOn(profile, dirName) { const cwd = path.join(SUITE_ROOT, dirName); fs.mkdirSync(cwd, { recursive: true }); const lane = lanesLib.createLane({ title: dirName, cwd, router_profile: profile }); applyRouterToLane(lane, profile); return { lane, settings: path.join(cwd, ".claude", "settings.local.json") }; } const envOf = (file) => JSON.parse(fs.readFileSync(file, "utf8")).env; it("pushes a rotated key out to every claude lane on that profile", () => { const { name } = saveNineRouter(); const a = laneOn(name, "rotate-a"); const b = laneOn(name, "rotate-b"); assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-secret-value"); routers.updateRouter(name, { apiKey: "sk-rotated" }); const result = reapplyToLanes(name); assert.equal(result.updated, 2); assert.deepEqual(result.failed, []); assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated"); assert.equal(envOf(b.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated"); }); it("leaves lanes on a different profile alone", () => { const { name } = saveNineRouter(); const other = routers.createRouter({ label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", apiKey: "sk-other", model: "auto", }); const mine = laneOn(name, "mixed-mine"); const theirs = laneOn(other.name, "mixed-theirs"); routers.updateRouter(name, { apiKey: "sk-rotated" }); reapplyToLanes(name); assert.equal(envOf(mine.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated"); assert.equal(envOf(theirs.settings).ANTHROPIC_AUTH_TOKEN, "sk-other"); }); it("clears the deleted router out of the lane's settings and its DB row", () => { // Without this the lane keeps routing to an endpoint the user believes they // removed, with a key they believe they revoked. const { name } = saveNineRouter(); const { lane, settings } = laneOn(name, "deleted-router"); routers.removeRouter(name); const result = detachFromLanes(name); assert.equal(result.updated, 1); assert.equal(envOf(settings).ANTHROPIC_BASE_URL, ""); assert.equal(envOf(settings).ANTHROPIC_AUTH_TOKEN, ""); assert.equal(envOf(settings).ANTHROPIC_MODEL, undefined); assert.equal(lanesLib.getLane(lane.id).router_profile, null); }); it("deletes the codex layer file, so a later same-named profile cannot inherit it", () => { const { name } = saveNineRouter(); const layer = path.join(process.env.CODEX_HOME, `${name}.config.toml`); applyRouterToLane(codexLane(), name); assert.ok(fs.existsSync(layer)); routers.removeRouter(name); detachFromLanes(name); assert.equal(fs.existsSync(layer), false); }); it("reports a lane it could not update instead of failing the whole edit", () => { const { name } = saveNineRouter(); const ok = laneOn(name, "partial-ok"); const broken = laneOn(name, "partial-broken"); fs.writeFileSync(broken.settings, "{ not json"); const result = reapplyToLanes(name); assert.equal(result.updated, 1); assert.equal(result.failed.length, 1); assert.match(result.failed[0], /partial-broken/); assert.equal(envOf(ok.settings).ANTHROPIC_BASE_URL, "http://localhost:20128/v1"); }); it("has nothing to do for native, which is stored as no router at all", () => { laneOn("native", "native-lane"); assert.deepEqual(reapplyToLanes("native"), { updated: 0, failed: [] }); }); }); describe("router-apply — what a spawn adds", () => { it("gives a codex lane the profile flag and the key in its environment", () => { const { name } = saveNineRouter(); assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), ["-p", name]); assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), { AGENTHUB_ROUTER_KEY: "sk-secret-value", }); }); it("adds nothing for a claude lane, whose settings file already carries it", () => { const { name } = saveNineRouter(); assert.deepEqual(routerArgvForSpawn({ ...claudeLane(), router_profile: name }), []); assert.deepEqual(routerEnvForSpawn({ ...claudeLane(), router_profile: name }), {}); }); it("adds nothing for a lane with no router, or one set to native", () => { assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: null }), []); assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: "native" }), []); assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: "native" }), {}); }); it("adds nothing when the profile a lane names has since been deleted", () => { // Deleting a profile must degrade the lane to its base config, never crash // the spawn that was about to start. const { name } = saveNineRouter(); routers.removeRouter(name); assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), []); assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), {}); }); });