Files
AgentHub/server/__tests__/router-apply.test.js
T
nntrivi2001 c507d37baf feat: AI Router per session; Settings at the sidebar's foot; shipped tool icons
AI Router is a session's choice, not a workspace's:
- an AI Router select beside the model picker in the workspace home and a
  pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only
  Native; last pick remembered in agenthub.session.router)
- `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets
  --settings=<data dir>/router-settings/<name>.json (0600, outranks project
  and user settings, key never in argv), Codex -p <name> + key via tmux -e
- dashboard_runs.router; a resume naming none goes back to it
- profile PATCH rewrites the session files, DELETE removes them
- gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace
  router radio, the claude-/codex-home `routers` answers; clearLaneRouters
  takes the old keys out of each workspace's settings.local.json at start

Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked
on the rail) instead of in the brand's corner.

Icons: shipped SVG marks for claude, codex, openclaw and hermes (a
black/white pair swapped by theme) in client/public/ai-tools/, used by
AiToolIcon before the server's favicon.
2026-10-02 14:19:02 +07:00

231 lines
8.4 KiB
JavaScript

/**
* @file Tests for starting one session on an AI Router profile — Claude Code's
* `--settings=<file>` env file and the Codex `<name>.config.toml` layer, an
* edit or delete reaching those files, and the one-time cleanup of the old
* per-workspace router out of a workspace's `settings.local.json`.
* @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_DATA_DIR = path.join(SUITE_ROOT, "data");
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 { db } = require("../db");
const {
resolveRouter,
resumeRouter,
routerForSpawn,
refreshProfileFiles,
removeProfileFiles,
clearLaneRouters,
} = require("../lib/router-apply");
const settingsFile = (name) =>
path.join(process.env.DASHBOARD_DATA_DIR, "router-settings", `${name}.json`);
const layerFile = (name) => path.join(process.env.CODEX_HOME, `${name}.config.toml`);
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => {
for (const dir of [
path.join(process.env.CLAUDE_HOME, "agenthub-routers"),
process.env.CODEX_HOME,
process.env.DASHBOARD_DATA_DIR,
]) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function saveNineRouter() {
return routers.createRouter({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
});
}
const spawnFor = (tool, name) => routerForSpawn(tool, resolveRouter(tool, name));
describe("resolveRouter", () => {
it("reads null, empty and native as the vendor's own API", () => {
for (const name of [null, undefined, "", "native"])
assert.equal(resolveRouter("claude", name), null);
});
it("refuses a profile the store does not have", () => {
assert.throws(
() => resolveRouter("claude", "nope"),
(e) => e.code === "EBADROUTER"
);
});
it("refuses a router on a tool with no endpoint to point at one", () => {
const { name } = saveNineRouter();
assert.throws(
() => resolveRouter("antigravity", name),
(e) => e.code === "EUNSUPPORTED"
);
assert.equal(resolveRouter("antigravity", "native"), null);
});
});
describe("routerForSpawn — Claude", () => {
it("passes a 0600 settings file holding the eight env keys, never the key in argv", () => {
const { name } = saveNineRouter();
const { argv, env } = spawnFor("claude", name);
assert.deepEqual(argv, [`--settings=${settingsFile(name)}`]);
assert.deepEqual(env, {});
assert.ok(!argv.join(" ").includes("sk-secret-value"));
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal(written.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
assert.equal(written.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
for (const k of [
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
]) {
assert.equal(written[k], "Highest", k);
}
assert.equal(fs.statSync(settingsFile(name)).mode & 0o777, 0o600);
});
it("leaves a blank key out instead of blanking the user's own setting", () => {
const { name } = routers.createRouter({
label: "Local",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
model: "Highest",
});
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal("ANTHROPIC_AUTH_TOKEN" in written, false);
});
it("adds nothing for native", () => {
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
});
});
describe("routerForSpawn — Codex", () => {
it("writes a profile layer, never the base config, and hands the key over in env", () => {
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();
const { argv, env } = spawnFor("codex", name);
assert.deepEqual(argv, ["-p", name]);
assert.deepEqual(env, { AGENTHUB_ROUTER_KEY: "sk-secret-value" });
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
const layer = fs.readFileSync(layerFile(name), "utf8");
assert.match(layer, /model_provider = "9router"/);
assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/);
assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/);
// Not "chat": codex-cli 0.149 refuses to load it at all.
assert.match(layer, /wire_api = "responses"/);
assert.ok(!layer.includes("sk-secret-value"));
});
it("names no env_key when the profile has no key", () => {
const { name } = routers.createRouter({
label: "Local gateway",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
model: "Highest",
});
const { env } = spawnFor("codex", name);
assert.deepEqual(env, {});
assert.ok(!fs.readFileSync(layerFile(name), "utf8").includes("env_key"));
});
});
describe("an edit or a delete reaches the session files", () => {
it("an edit rewrites the files that exist and creates none", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
routers.updateRouter(name, { apiKey: "sk-rotated" });
refreshProfileFiles(name);
const env = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-rotated");
assert.equal(fs.existsSync(layerFile(name)), false);
});
it("a delete removes both files", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
spawnFor("codex", name);
removeProfileFiles(name);
assert.equal(fs.existsSync(settingsFile(name)), false);
assert.equal(fs.existsSync(layerFile(name)), false);
});
});
describe("resumeRouter", () => {
it("goes back to the router the session last ran on, while it exists", () => {
const { name } = saveNineRouter();
const runs = require("../lib/dashboard-runs");
runs.recordRun({
id: "agenthub-lane-91",
sessionId: "sess-router-1",
mode: "interactive",
router: name,
});
assert.equal(resumeRouter("sess-router-1"), name);
routers.removeRouter(name);
assert.equal(resumeRouter("sess-router-1"), null);
assert.equal(resumeRouter("sess-unknown"), null);
});
});
describe("clearLaneRouters", () => {
it("takes the old router's keys out of a workspace's settings and clears the column", () => {
const cwd = path.join(SUITE_ROOT, "old-lane");
const file = path.join(cwd, ".claude", "settings.local.json");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
JSON.stringify({
permissions: { allow: ["Bash(ls)"] },
env: { ANTHROPIC_BASE_URL: "http://x", ANTHROPIC_AUTH_TOKEN: "sk", MY_VAR: "1" },
})
);
const lane = lanesLib.createLane({ title: "old", cwd, router_profile: "9router" });
clearLaneRouters();
const data = JSON.parse(fs.readFileSync(file, "utf8"));
assert.deepEqual(data.env, { MY_VAR: "1" });
assert.deepEqual(data.permissions, { allow: ["Bash(ls)"] });
assert.equal(
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
null
);
});
it("keeps the column of a workspace whose file it could not clean, to retry later", () => {
const cwd = path.join(SUITE_ROOT, "broken-lane");
const file = path.join(cwd, ".claude", "settings.local.json");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "{ not json");
const lane = lanesLib.createLane({ title: "broken", cwd, router_profile: "9router" });
clearLaneRouters();
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
assert.equal(
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
"9router"
);
});
});