Files
AgentHub/server/__tests__/router-apply.test.js
T
nntrivi2001 9d096b61f7 feat(lanes): Antigravity lanes, release exited panes, fresh Codex starts
- antigravity: a third lane tool running Antigravity's `agy` CLI. `-i`
  carries the first prompt, `--continue` resumes; `agy` has no model,
  effort or permission flag, and `--dangerously-skip-permissions` is never
  emitted. No pipeline, no agents install, and no AI Router (native only,
  else EUNSUPPORTED). Add lane gains an Antigravity choice and the binary
  probe maps it to `agy`.
- UI: the lane card no longer renders a frame holding only a "Codex" chip,
  and the prompt box names the lane's CLI ("Ask Codex anything…"), via
  LANE_TOOL_LABEL.
- runs: a pane that exits is released on a 10 s server beat
  (healAllRunIds, DASHBOARD_RUN_HEAL_MS). Codex and Antigravity fire no
  hook, so their lanes stayed "running" until something re-read them.
- codex: Start no longer sends resumeLast — only "Resume last session"
  continues the previous thread.
2026-09-28 10:22:07 +07:00

443 lines
18 KiB
JavaScript

/**
* @file Tests for attaching an AI Router profile to a lane — the Claude Code
* `settings.local.json` env merge and the Codex `<name>.config.toml` layer,
* including the guarantees that neither write destroys anything already there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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/<name>/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 }), {});
});
});