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
This commit is contained in:
2026-10-06 22:48:09 +07:00
parent dd9b23106c
commit 5fe5d51b1b
82 changed files with 1792 additions and 1412 deletions
+330
View File
@@ -0,0 +1,330 @@
/**
* @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",
});
});
});