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`.
191 lines
7.3 KiB
JavaScript
191 lines
7.3 KiB
JavaScript
/**
|
||
* @file Tests for the AI Router profile store — the CRUD surface, the built-in
|
||
* native profile, and the guarantee that a stored API key never appears in a
|
||
* response the browser can read.
|
||
* @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-ai-routers-"));
|
||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||
|
||
const { describe, it, beforeEach, after } = require("node:test");
|
||
const assert = require("node:assert/strict");
|
||
|
||
const routers = require("../lib/ai-routers");
|
||
|
||
const ROUTERS_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
|
||
|
||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||
|
||
beforeEach(() => fs.rmSync(ROUTERS_DIR, { recursive: true, force: true }));
|
||
|
||
/** The smallest profile the API accepts. */
|
||
function nineRouter(over = {}) {
|
||
return {
|
||
label: "9Router",
|
||
baseUrl: "http://localhost:20128/v1",
|
||
apiKey: "sk-secret-value",
|
||
model: "Highest",
|
||
...over,
|
||
};
|
||
}
|
||
|
||
describe("ai-routers — the built-in native profile", () => {
|
||
it("is listed even before anything has been saved", () => {
|
||
const { items } = routers.listRouters();
|
||
assert.equal(items.length, 1);
|
||
assert.equal(items[0].name, "native");
|
||
assert.equal(items[0].builtin, true);
|
||
});
|
||
|
||
it("leaves the two connection variables empty rather than absent", () => {
|
||
// The reference native settings.json sets both to "" — an absent key would
|
||
// let a user-level router base URL leak into a lane meant to run native.
|
||
const native = routers.getRouter("native");
|
||
assert.equal(native.baseUrl, "");
|
||
assert.equal(native.apiKey, "");
|
||
});
|
||
|
||
it("refuses to be edited or removed", () => {
|
||
assert.throws(() => routers.updateRouter("native", { label: "mine" }), { code: "EBUILTIN" });
|
||
assert.throws(() => routers.removeRouter("native"), { code: "EBUILTIN" });
|
||
});
|
||
});
|
||
|
||
describe("ai-routers — create", () => {
|
||
it("saves a profile and derives its name from the label", () => {
|
||
const created = routers.createRouter(nineRouter());
|
||
assert.equal(created.name, "9Router".toLowerCase());
|
||
assert.equal(created.label, "9Router");
|
||
assert.ok(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)));
|
||
});
|
||
|
||
it("stores the file 0600 inside a 0700 directory", () => {
|
||
const created = routers.createRouter(nineRouter());
|
||
const file = fs.statSync(path.join(ROUTERS_DIR, `${created.name}.json`));
|
||
const dir = fs.statSync(ROUTERS_DIR);
|
||
assert.equal(file.mode & 0o777, 0o600);
|
||
assert.equal(dir.mode & 0o777, 0o700);
|
||
});
|
||
|
||
it("fills every model slot from `model` when no per-tier value is given", () => {
|
||
// The 9Router reference sets all six slots to one symbolic value; typing it
|
||
// once must be enough.
|
||
routers.createRouter(nineRouter());
|
||
const saved = routers.getRouter("9router");
|
||
assert.equal(saved.models.opus, "Highest");
|
||
assert.equal(saved.models.sonnet, "Highest");
|
||
assert.equal(saved.models.haiku, "Highest");
|
||
assert.equal(saved.models.smallFast, "Highest");
|
||
assert.equal(saved.models.subagent, "Highest");
|
||
});
|
||
|
||
it("keeps a per-tier value that was given explicitly", () => {
|
||
routers.createRouter(nineRouter({ models: { opus: "PremiumCombo" } }));
|
||
const saved = routers.getRouter("9router");
|
||
assert.equal(saved.models.opus, "PremiumCombo");
|
||
assert.equal(saved.models.sonnet, "Highest");
|
||
});
|
||
|
||
it("gives a second profile with the same label a free name", () => {
|
||
const a = routers.createRouter(nineRouter());
|
||
const b = routers.createRouter(nineRouter());
|
||
assert.notEqual(a.name, b.name);
|
||
});
|
||
|
||
it("rejects a label that is empty or carries control characters", () => {
|
||
assert.throws(() => routers.createRouter(nineRouter({ label: " " })), { code: "EBADLABEL" });
|
||
assert.throws(() => routers.createRouter(nineRouter({ label: "ab" })), {
|
||
code: "EBADLABEL",
|
||
});
|
||
});
|
||
|
||
it("rejects a base URL that is not http(s)", () => {
|
||
// A `file:` or `javascript:` base URL is never a router; it is either a
|
||
// mistake or an attempt to point the CLI somewhere it should not go.
|
||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
||
code: "EBADURL",
|
||
});
|
||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "not a url" })), {
|
||
code: "EBADURL",
|
||
});
|
||
});
|
||
|
||
it("refuses to shadow the built-in native name", () => {
|
||
assert.throws(() => routers.createRouter(nineRouter({ label: "native" })), {
|
||
code: "EBUILTIN",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("ai-routers — the key never reaches the browser", () => {
|
||
it("is absent from the list, which reports only that one is set", () => {
|
||
routers.createRouter(nineRouter());
|
||
const { items } = routers.listRouters();
|
||
const saved = items.find((i) => i.name === "9router");
|
||
assert.equal(saved.hasKey, true);
|
||
assert.equal(saved.apiKey, undefined);
|
||
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
|
||
});
|
||
|
||
it("is still absent when the profile has no key at all", () => {
|
||
routers.createRouter(nineRouter({ apiKey: "" }));
|
||
const { items } = routers.listRouters();
|
||
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
|
||
});
|
||
});
|
||
|
||
describe("ai-routers — update", () => {
|
||
it("changes the label without changing the name a lane already references", () => {
|
||
// A lane stores the name. Renaming the label must not orphan that lane.
|
||
const created = routers.createRouter(nineRouter());
|
||
const updated = routers.updateRouter(created.name, { label: "9Router (nhà)" });
|
||
assert.equal(updated.name, created.name);
|
||
assert.equal(updated.label, "9Router (nhà)");
|
||
});
|
||
|
||
it("keeps the stored key when the update omits it", () => {
|
||
// The edit form cannot show the key, so it cannot send it back either.
|
||
const created = routers.createRouter(nineRouter());
|
||
routers.updateRouter(created.name, { label: "renamed" });
|
||
assert.equal(routers.getRouter(created.name).apiKey, "sk-secret-value");
|
||
});
|
||
|
||
it("replaces the key when a new one is given", () => {
|
||
const created = routers.createRouter(nineRouter());
|
||
routers.updateRouter(created.name, { apiKey: "sk-rotated" });
|
||
assert.equal(routers.getRouter(created.name).apiKey, "sk-rotated");
|
||
});
|
||
|
||
it("reports a name that does not exist", () => {
|
||
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
|
||
});
|
||
});
|
||
|
||
describe("ai-routers — remove", () => {
|
||
it("deletes the file", () => {
|
||
const created = routers.createRouter(nineRouter());
|
||
routers.removeRouter(created.name);
|
||
assert.equal(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)), false);
|
||
assert.equal(routers.listRouters().items.length, 1); // native only
|
||
});
|
||
|
||
it("reports a name that does not exist", () => {
|
||
assert.throws(() => routers.removeRouter("ghost"), { code: "ENOTFOUND" });
|
||
});
|
||
});
|
||
|
||
describe("ai-routers — a corrupt file is skipped, never fatal", () => {
|
||
it("lists the healthy profiles around it", () => {
|
||
routers.createRouter(nineRouter());
|
||
fs.writeFileSync(path.join(ROUTERS_DIR, "broken.json"), "{not json");
|
||
const { items } = routers.listRouters();
|
||
assert.ok(items.some((i) => i.name === "9router"));
|
||
assert.ok(!items.some((i) => i.name === "broken"));
|
||
});
|
||
});
|