0a86dfb8c5
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.
The two CLIs are configured differently, and the difference is theirs:
- a claude lane gets eight `env` keys merged into its own
`.claude/settings.local.json` (`.local` because the value holds a key);
- a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
`codex -p <name>` loads — because Codex has NO project-level config.
Its base `config.toml` is never touched.
Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).
Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.
Three things this had to get right, each found by review and verified
against the real tools rather than assumed:
- `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
supported"), so every codex lane on a router would have failed to start.
The layer writes "responses", and writes `env_key` only when the profile
actually has a key — naming an unset variable is itself a startup error.
- The key must never be TYPED into a pane. `tmux.sendCommand` used
`send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
every attached browser and bash appends to history; it now takes over the
idle pane with `respawn-pane -k -e`, passing env through argv the way
`newSession` always has. Verified on tmux 3.4: multi-element argv arrives
intact and the pane renders no trace of the value.
- Attaching COPIES a profile, so editing or deleting one in Settings has to
reach the lanes already using it. An edit re-applies it (a rotated key
would otherwise never reach the lane still holding the revoked one); a
delete resets those lanes to native and removes the Codex layer file.
Both report per-lane failures, which the tab surfaces.
`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.
DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
390 lines
17 KiB
JavaScript
390 lines
17 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 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", () => {
|
|
it("excludes settings.local.json from git, so `git clean -fd` cannot delete it", () => {
|
|
// `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.
|
|
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
|
const { name } = saveNineRouter();
|
|
applyRouterToLane(claudeLane(), name);
|
|
|
|
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
|
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
|
});
|
|
|
|
it("excludes it for a native lane too, not only a lane on a router", () => {
|
|
// The blanked connection pair is load-bearing in its own right: losing it
|
|
// to `git clean -fd` lets a USER-level router take over a lane that
|
|
// explicitly asked for native.
|
|
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
|
applyRouterToLane(claudeLane(), "native");
|
|
|
|
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
|
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
|
});
|
|
|
|
it("resolves the real git dir when .git is a file, as it is in every worktree lane", () => {
|
|
// A linked worktree's `.git` is a pointer file; joining ".git/info" onto it
|
|
// would be a path that cannot exist.
|
|
const gitDir = path.join(SUITE_ROOT, "realgit", "worktrees", "lane1");
|
|
fs.mkdirSync(gitDir, { recursive: true });
|
|
fs.writeFileSync(path.join(LANE_CWD, ".git"), `gitdir: ${gitDir}\n`);
|
|
const { name } = saveNineRouter();
|
|
applyRouterToLane(claudeLane(), name);
|
|
|
|
const exclude = fs.readFileSync(path.join(gitDir, "info", "exclude"), "utf8");
|
|
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
|
});
|
|
|
|
it("adds the entry once, however many times a router is applied", () => {
|
|
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
|
const { name } = saveNineRouter();
|
|
applyRouterToLane(claudeLane(), name);
|
|
applyRouterToLane(claudeLane(), "native");
|
|
|
|
const exclude = fs.readFileSync(path.join(LANE_CWD, ".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 — 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 }), {});
|
|
});
|
|
});
|