9d096b61f7
- 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.
443 lines
18 KiB
JavaScript
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 }), {});
|
|
});
|
|
});
|