Files
AgentHub/server/__tests__/ai-proxies.test.js
T
nntrivi2001 5fe5d51b1b feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins
AI Proxy (was AI Router):
- server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at
  /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the
  start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/
- dashboard_runs.router renamed in place to proxy: an older build on the same
  DB fails on that column, so dev and prod sharing one DB update together
- a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages);
  Test probes Claude /v1/messages and Codex /v1/responses
- Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker

Updates:
- every tab reloads onto the new UI build when its WebSocket reconnects to a
  server of another version (lib/newBuildReload.ts, once per version)
- Update now stops waiting on a plugin chain that failed or pulled nothing
  (the old server never goes down), and reloads as soon as a new version answers

Also:
- a pane whose command exits at once says so (EPANEEXITED) instead of
  "Command failed: tmux set-option … mouse on"
- the pane drag preview draws where the pane actually lands
2026-10-06 22:48:09 +07:00

331 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Tests for the AI Proxy 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-proxies-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const proxies = require("../lib/ai-proxies");
const PROXIES_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-proxies");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => fs.rmSync(PROXIES_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-proxies — the built-in native profile", () => {
it("is listed even before anything has been saved", () => {
const { items } = proxies.listProxies();
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 proxy base URL leak into a lane meant to run native.
const native = proxies.getProxy("native");
assert.equal(native.baseUrl, "");
assert.equal(native.apiKey, "");
});
it("carries no model ids of its own — the session's model picker picks", () => {
const native = proxies.getProxy("native");
assert.equal(native.model, "");
assert.deepEqual(native.models, {});
});
it("refuses to be edited or removed", () => {
assert.throws(() => proxies.updateProxy("native", { label: "mine" }), { code: "EBUILTIN" });
assert.throws(() => proxies.removeProxy("native"), { code: "EBUILTIN" });
});
});
describe("ai-proxies — create", () => {
it("saves a profile and derives its name from the label", () => {
const created = proxies.createProxy(nineRouter());
assert.equal(created.name, "9Router".toLowerCase());
assert.equal(created.label, "9Router");
assert.ok(fs.existsSync(path.join(PROXIES_DIR, `${created.name}.json`)));
});
it("stores the file 0600 inside a 0700 directory", () => {
const created = proxies.createProxy(nineRouter());
const file = fs.statSync(path.join(PROXIES_DIR, `${created.name}.json`));
const dir = fs.statSync(PROXIES_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.
proxies.createProxy(nineRouter());
const saved = proxies.getProxy("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", () => {
proxies.createProxy(nineRouter({ models: { opus: "PremiumCombo" } }));
const saved = proxies.getProxy("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 = proxies.createProxy(nineRouter());
const b = proxies.createProxy(nineRouter());
assert.notEqual(a.name, b.name);
});
it("rejects a label that is empty or carries control characters", () => {
assert.throws(() => proxies.createProxy(nineRouter({ label: " " })), { code: "EBADLABEL" });
assert.throws(() => proxies.createProxy(nineRouter({ label: "ab" })), {
code: "EBADLABEL",
});
});
it("rejects a base URL that is not http(s)", () => {
// A `file:` or `javascript:` base URL is never a proxy; it is either a
// mistake or an attempt to point the CLI somewhere it should not go.
assert.throws(() => proxies.createProxy(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
assert.throws(() => proxies.createProxy(nineRouter({ baseUrl: "not a url" })), {
code: "EBADURL",
});
});
it("refuses to shadow the built-in native name", () => {
assert.throws(() => proxies.createProxy(nineRouter({ label: "native" })), {
code: "EBUILTIN",
});
});
});
describe("ai-proxies — the key never reaches the browser", () => {
it("is absent from the list, which reports only that one is set", () => {
proxies.createProxy(nineRouter());
const { items } = proxies.listProxies();
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", () => {
proxies.createProxy(nineRouter({ apiKey: "" }));
const { items } = proxies.listProxies();
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
});
});
describe("ai-proxies — 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 = proxies.createProxy(nineRouter());
const updated = proxies.updateProxy(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 = proxies.createProxy(nineRouter());
proxies.updateProxy(created.name, { label: "renamed" });
assert.equal(proxies.getProxy(created.name).apiKey, "sk-secret-value");
});
it("replaces the key when a new one is given", () => {
const created = proxies.createProxy(nineRouter());
proxies.updateProxy(created.name, { apiKey: "sk-rotated" });
assert.equal(proxies.getProxy(created.name).apiKey, "sk-rotated");
});
it("reports a name that does not exist", () => {
assert.throws(() => proxies.updateProxy("ghost", { label: "x" }), { code: "ENOTFOUND" });
});
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
const created = proxies.createProxy(nineRouter({ behavesAs: " claude-opus-5-5 " }));
assert.equal(created.behavesAs, "claude-opus-5-5");
assert.equal(
proxies.updateProxy(created.name, { label: "renamed" }).behavesAs,
"claude-opus-5-5"
);
assert.equal(proxies.updateProxy(created.name, { behavesAs: "" }).behavesAs, "");
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
assert.throws(() => proxies.updateProxy(created.name, { behavesAs: bad }), {
code: "EBADMODEL",
});
}
});
});
describe("ai-proxies — remove", () => {
it("deletes the file", () => {
const created = proxies.createProxy(nineRouter());
proxies.removeProxy(created.name);
assert.equal(fs.existsSync(path.join(PROXIES_DIR, `${created.name}.json`)), false);
assert.equal(proxies.listProxies().items.length, 1); // native only
});
it("reports a name that does not exist", () => {
assert.throws(() => proxies.removeProxy("ghost"), { code: "ENOTFOUND" });
});
});
describe("ai-proxies — a corrupt file is skipped, never fatal", () => {
it("lists the healthy profiles around it", () => {
proxies.createProxy(nineRouter());
fs.writeFileSync(path.join(PROXIES_DIR, "broken.json"), "{not json");
const { items } = proxies.listProxies();
assert.ok(items.some((i) => i.name === "9router"));
assert.ok(!items.some((i) => i.name === "broken"));
});
});
describe("ai-proxies — base URL is the root", () => {
it("drops a trailing /v1 on save", () => {
const created = proxies.createProxy(nineRouter());
assert.equal(created.baseUrl, "http://localhost:20128");
});
it("reads a /v1 an older build stored as the root", () => {
fs.mkdirSync(PROXIES_DIR, { recursive: true });
fs.writeFileSync(
path.join(PROXIES_DIR, "old.json"),
JSON.stringify({ ...nineRouter({ baseUrl: "http://localhost:20128/v1/" }), models: {} })
);
assert.equal(proxies.getProxy("old").baseUrl, "http://localhost:20128");
});
});
describe("ai-proxies — the pre-rename store", () => {
const LEGACY = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
after(() => fs.rmSync(LEGACY, { recursive: true, force: true }));
it("moves agenthub-routers/ here once and leaves a symlink behind", () => {
fs.mkdirSync(LEGACY, { recursive: true });
fs.writeFileSync(path.join(LEGACY, "home.json"), JSON.stringify(nineRouter({ models: {} })));
assert.ok(proxies.listProxies().items.some((i) => i.name === "home"));
assert.ok(fs.existsSync(path.join(PROXIES_DIR, "home.json")));
assert.ok(fs.lstatSync(LEGACY).isSymbolicLink());
// An older build reading the old path still finds the profile.
assert.ok(fs.existsSync(path.join(LEGACY, "home.json")));
});
});
describe("ai-proxies — testProxy", () => {
const realFetch = global.fetch;
after(() => {
global.fetch = realFetch;
});
/** Capture the one request testProxy makes, and answer it however asked. */
function stubFetch(answer) {
const calls = [];
global.fetch = async (url, init) => {
calls.push({ url, init });
if (answer instanceof Error) throw answer;
return answer;
};
return calls;
}
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
it("probes Claude's /v1/messages and Codex's /v1/responses on the root", async () => {
const calls = stubFetch(ok());
const result = await proxies.testProxy(nineRouter());
assert.deepEqual(result.claude, { ok: true, status: 200, message: "Highest" });
assert.deepEqual(result.codex, { ok: true, status: 200, message: "Highest" });
const byUrl = Object.fromEntries(calls.map((c) => [c.url, c.init]));
// The typed `/v1` is dropped: one `/v1`, never two.
const claude = byUrl["http://localhost:20128/v1/messages"];
const codex = byUrl["http://localhost:20128/v1/responses"];
assert.equal(JSON.parse(claude.body).max_tokens, 1);
assert.equal(JSON.parse(claude.body).model, "Highest");
assert.equal(claude.headers["x-api-key"], "sk-secret-value");
assert.equal(JSON.parse(codex.body).model, "Highest");
assert.equal(codex.headers.authorization, "Bearer sk-secret-value");
});
it("reports a proxy that answered with an error rather than throwing", async () => {
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
const result = await proxies.testProxy(nineRouter());
assert.deepEqual(result.claude, { ok: false, status: 401, message: "bad key" });
assert.deepEqual(result.codex, { ok: false, status: 401, message: "bad key" });
});
it("reports an endpoint that never answered", async () => {
// The shape node's own fetch throws: a useless outer message wrapping the
// one thing the user can act on.
stubFetch(
Object.assign(new Error("fetch failed"), {
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
})
);
const { claude } = await proxies.testProxy(nineRouter());
assert.equal(claude.ok, false);
assert.equal(claude.status, null);
assert.match(claude.message, /ECONNREFUSED/);
});
it("falls back to the stored key for the endpoint it was stored against", async () => {
const created = proxies.createProxy(nineRouter());
const calls = stubFetch(ok());
await proxies.testProxy({
name: created.name,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
});
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("never sends the stored key to a different endpoint", async () => {
const created = proxies.createProxy(nineRouter());
const calls = stubFetch(ok());
await assert.rejects(
proxies.testProxy({
name: created.name,
baseUrl: "https://attacker.example/v1",
model: "Highest",
}),
{ code: "EBADKEY" }
);
assert.equal(calls.length, 0);
});
it("refuses a base URL that is not http(s)", async () => {
await assert.rejects(proxies.testProxy(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
});
});