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:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
/**
|
||||
* @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" });
|
||||
});
|
||||
|
||||
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
|
||||
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
|
||||
assert.equal(created.behavesAs, "claude-opus-5-5");
|
||||
assert.equal(
|
||||
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
|
||||
"claude-opus-5-5"
|
||||
);
|
||||
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
|
||||
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
|
||||
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
|
||||
code: "EBADMODEL",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — testRouter", () => {
|
||||
const realFetch = global.fetch;
|
||||
after(() => {
|
||||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
/** Capture the one request testRouter 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("posts one capped message to the endpoint's /v1/messages", async () => {
|
||||
const calls = stubFetch(ok());
|
||||
const result = await routers.testRouter(nineRouter());
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "http://localhost:20128/v1/v1/messages");
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.equal(body.max_tokens, 1);
|
||||
assert.equal(body.model, "Highest");
|
||||
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
|
||||
});
|
||||
|
||||
it("reports a router that answered with an error rather than throwing", async () => {
|
||||
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
|
||||
const result = await routers.testRouter(nineRouter());
|
||||
assert.deepEqual(result, { 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 result = await routers.testRouter(nineRouter());
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, null);
|
||||
assert.match(result.message, /ECONNREFUSED/);
|
||||
});
|
||||
|
||||
it("falls back to the stored key for the endpoint it was stored against", async () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const calls = stubFetch(ok());
|
||||
await routers.testRouter({
|
||||
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 = routers.createRouter(nineRouter());
|
||||
const calls = stubFetch(ok());
|
||||
await assert.rejects(
|
||||
routers.testRouter({
|
||||
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(routers.testRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
||||
code: "EBADURL",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -195,7 +195,7 @@ describe("enable / default", () => {
|
||||
prompt: { mode: "flag" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
proxyCapable: false,
|
||||
modelFlag: false,
|
||||
defaultModel: null,
|
||||
modelVendor: null,
|
||||
@@ -203,7 +203,7 @@ describe("enable / default", () => {
|
||||
install: null,
|
||||
update: null,
|
||||
});
|
||||
assert.equal(v.tools.find((t) => t.id === "codex").routerCapable, true);
|
||||
assert.equal(v.tools.find((t) => t.id === "codex").proxyCapable, true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -607,7 +607,7 @@ describe("patchSettings — backup and atomicity", () => {
|
||||
});
|
||||
|
||||
it("writes and backs up nothing when the patch changes nothing", () => {
|
||||
// Re-attaching the same AI Router profile sends the same eight env keys
|
||||
// Re-attaching the same AI Proxy profile sends the same eight env keys
|
||||
// every time. With an unconditional backup that left a `.bak` per apply
|
||||
// piling up inside the user's own project — a dozen untracked files in a
|
||||
// minute of switching lanes.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file Verifies db.js renames `dashboard_runs.router` (before the AI Router →
|
||||
* AI Proxy rename) to `proxy` in place, keeping each run's value, so a resume
|
||||
* still goes back to the proxy its session last ran on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
let TEST_DB;
|
||||
|
||||
before(() => {
|
||||
TEST_DB = path.join(os.tmpdir(), `dashboard-runs-router-${Date.now()}-${process.pid}.db`);
|
||||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||
const raw = new Database(TEST_DB);
|
||||
raw.exec(`
|
||||
CREATE TABLE dashboard_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
mode TEXT,
|
||||
cwd TEXT NOT NULL,
|
||||
model TEXT,
|
||||
permission_mode TEXT,
|
||||
effort TEXT,
|
||||
resume_session_id TEXT,
|
||||
prompt_preview TEXT,
|
||||
status TEXT NOT NULL,
|
||||
exit_code INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
ended_at TEXT,
|
||||
router TEXT
|
||||
);
|
||||
`);
|
||||
raw
|
||||
.prepare(
|
||||
"INSERT INTO dashboard_runs (id, session_id, cwd, status, router) VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run("agenthub-lane-1", "sess-1", "/tmp/x", "completed", "9router");
|
||||
raw.close();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
for (const suffix of ["", "-wal", "-shm", "-migrate.lock"]) {
|
||||
fs.rmSync(TEST_DB + suffix, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("dashboard_runs.router → proxy", () => {
|
||||
it("renames the column and keeps the value", () => {
|
||||
const { db } = require("../db");
|
||||
const cols = db
|
||||
.prepare("PRAGMA table_info(dashboard_runs)")
|
||||
.all()
|
||||
.map((c) => c.name);
|
||||
assert.ok(cols.includes("proxy"));
|
||||
assert.ok(!cols.includes("router"));
|
||||
assert.equal(require("../lib/dashboard-runs").proxyForSession("sess-1"), "9router");
|
||||
});
|
||||
});
|
||||
@@ -247,7 +247,7 @@ describe("GET /api/lanes/:id/git/raw", () => {
|
||||
});
|
||||
|
||||
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
|
||||
it("answers 403 for the router's settings file", async () => {
|
||||
it("answers 403 for the old proxy key file (settings.local.json)", async () => {
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
@@ -331,19 +331,22 @@ describe("/api/lanes", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("starts a session on the router it names, and refuses one the store lacks", async () => {
|
||||
it("starts a session on the proxy it names, and refuses one the store lacks", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-start-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-proxy-start-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
try {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw new Error("no such session");
|
||||
return "";
|
||||
});
|
||||
const bad = await request("POST", `/api/lanes/${id}/start`, { router: "nope" });
|
||||
const bad = await request("POST", `/api/lanes/${id}/start`, { proxy: "nope" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADROUTER");
|
||||
// The per-workspace route is gone: a router belongs to a session now.
|
||||
assert.equal(bad.body.error.code, "EBADPROXY");
|
||||
// The pre-rename field still names a proxy, for a client older than it.
|
||||
const legacy = await request("POST", `/api/lanes/${id}/start`, { router: "nope" });
|
||||
assert.equal(legacy.body.error.code, "EBADPROXY");
|
||||
// The per-workspace route is gone: a proxy belongs to a session now.
|
||||
const gone = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(gone.status, 404);
|
||||
} finally {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file Tests for starting one session on an AI Router profile — Claude Code's
|
||||
* @file Tests for starting one session on an AI Proxy 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`.
|
||||
* per-workspace proxy out of a workspace's `settings.local.json`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@ 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-"));
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-proxy-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");
|
||||
@@ -19,27 +19,27 @@ 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 proxies = require("../lib/ai-proxies");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { db } = require("../db");
|
||||
const {
|
||||
resolveRouter,
|
||||
resumeRouter,
|
||||
routerForSpawn,
|
||||
resolveProxy,
|
||||
resumeProxy,
|
||||
proxyForSpawn,
|
||||
refreshProfileFiles,
|
||||
removeProfileFiles,
|
||||
clearLaneRouters,
|
||||
} = require("../lib/router-apply");
|
||||
clearLaneProxies,
|
||||
} = require("../lib/proxy-apply");
|
||||
|
||||
const settingsFile = (name) =>
|
||||
path.join(process.env.DASHBOARD_DATA_DIR, "router-settings", `${name}.json`);
|
||||
path.join(process.env.DASHBOARD_DATA_DIR, "proxy-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"),
|
||||
path.join(process.env.CLAUDE_HOME, "agenthub-proxies"),
|
||||
process.env.CODEX_HOME,
|
||||
process.env.DASHBOARD_DATA_DIR,
|
||||
]) {
|
||||
@@ -48,7 +48,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
function saveNineRouter() {
|
||||
return routers.createRouter({
|
||||
return proxies.createProxy({
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
@@ -56,32 +56,32 @@ function saveNineRouter() {
|
||||
});
|
||||
}
|
||||
|
||||
const spawnFor = (tool, name) => routerForSpawn(tool, resolveRouter(tool, name));
|
||||
const spawnFor = (tool, name) => proxyForSpawn(tool, resolveProxy(tool, name));
|
||||
|
||||
describe("resolveRouter", () => {
|
||||
describe("resolveProxy", () => {
|
||||
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);
|
||||
assert.equal(resolveProxy("claude", name), null);
|
||||
});
|
||||
|
||||
it("refuses a profile the store does not have", () => {
|
||||
assert.throws(
|
||||
() => resolveRouter("claude", "nope"),
|
||||
(e) => e.code === "EBADROUTER"
|
||||
() => resolveProxy("claude", "nope"),
|
||||
(e) => e.code === "EBADPROXY"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a router on a tool with no endpoint to point at one", () => {
|
||||
it("refuses a proxy on a tool with no endpoint to point at one", () => {
|
||||
const { name } = saveNineRouter();
|
||||
assert.throws(
|
||||
() => resolveRouter("antigravity", name),
|
||||
() => resolveProxy("antigravity", name),
|
||||
(e) => e.code === "EUNSUPPORTED"
|
||||
);
|
||||
assert.equal(resolveRouter("antigravity", "native"), null);
|
||||
assert.equal(resolveProxy("antigravity", "native"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routerForSpawn — Claude", () => {
|
||||
describe("proxyForSpawn — 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);
|
||||
@@ -89,7 +89,7 @@ describe("routerForSpawn — Claude", () => {
|
||||
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_BASE_URL, "http://localhost:20128"); // Claude Code appends /v1/messages
|
||||
assert.equal(written.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
|
||||
for (const k of [
|
||||
"ANTHROPIC_MODEL",
|
||||
@@ -105,7 +105,7 @@ describe("routerForSpawn — Claude", () => {
|
||||
});
|
||||
|
||||
it("leaves a blank key out instead of blanking the user's own setting", () => {
|
||||
const { name } = routers.createRouter({
|
||||
const { name } = proxies.createProxy({
|
||||
label: "Local",
|
||||
baseUrl: "http://127.0.0.1:20128/v1",
|
||||
apiKey: "",
|
||||
@@ -127,8 +127,8 @@ describe("routerForSpawn — Claude", () => {
|
||||
assert.equal("modelPicker" in written, false);
|
||||
});
|
||||
|
||||
it("maps every distinct router model name with behavesAs (issue #56)", () => {
|
||||
const { name } = routers.createRouter({
|
||||
it("maps every distinct proxy model name with behavesAs (issue #56)", () => {
|
||||
const { name } = proxies.createProxy({
|
||||
label: "9Router combo",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "agent",
|
||||
@@ -148,7 +148,7 @@ describe("routerForSpawn — Claude", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("routerForSpawn — Codex", () => {
|
||||
describe("proxyForSpawn — 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 });
|
||||
@@ -156,19 +156,19 @@ describe("routerForSpawn — Codex", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const { argv, env } = spawnFor("codex", name);
|
||||
assert.deepEqual(argv, ["-p", name]);
|
||||
assert.deepEqual(env, { AGENTHUB_ROUTER_KEY: "sk-secret-value" });
|
||||
assert.deepEqual(env, { AGENTHUB_PROXY_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"/);
|
||||
assert.match(layer, /env_key = "AGENTHUB_PROXY_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({
|
||||
const { name } = proxies.createProxy({
|
||||
label: "Local gateway",
|
||||
baseUrl: "http://127.0.0.1:20128/v1",
|
||||
apiKey: "",
|
||||
@@ -184,7 +184,7 @@ 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" });
|
||||
proxies.updateProxy(name, { apiKey: "sk-rotated" });
|
||||
refreshProfileFiles(name);
|
||||
const env = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
||||
@@ -201,25 +201,25 @@ describe("an edit or a delete reaches the session files", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeRouter", () => {
|
||||
it("goes back to the router the session last ran on, while it exists", () => {
|
||||
describe("resumeProxy", () => {
|
||||
it("goes back to the proxy 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",
|
||||
sessionId: "sess-proxy-1",
|
||||
mode: "interactive",
|
||||
router: name,
|
||||
proxy: name,
|
||||
});
|
||||
assert.equal(resumeRouter("sess-router-1"), name);
|
||||
routers.removeRouter(name);
|
||||
assert.equal(resumeRouter("sess-router-1"), null);
|
||||
assert.equal(resumeRouter("sess-unknown"), null);
|
||||
assert.equal(resumeProxy("sess-proxy-1"), name);
|
||||
proxies.removeProxy(name);
|
||||
assert.equal(resumeProxy("sess-proxy-1"), null);
|
||||
assert.equal(resumeProxy("sess-unknown"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearLaneRouters", () => {
|
||||
it("takes the old router's keys out of a workspace's settings and clears the column", () => {
|
||||
describe("clearLaneProxies", () => {
|
||||
it("takes the old proxy'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 });
|
||||
@@ -231,7 +231,7 @@ describe("clearLaneRouters", () => {
|
||||
})
|
||||
);
|
||||
const lane = lanesLib.createLane({ title: "old", cwd, router_profile: "9router" });
|
||||
clearLaneRouters();
|
||||
clearLaneProxies();
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.deepEqual(data.env, { MY_VAR: "1" });
|
||||
assert.deepEqual(data.permissions, { allow: ["Bash(ls)"] });
|
||||
@@ -247,7 +247,7 @@ describe("clearLaneRouters", () => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, "{ not json");
|
||||
const lane = lanesLib.createLane({ title: "broken", cwd, router_profile: "9router" });
|
||||
clearLaneRouters();
|
||||
clearLaneProxies();
|
||||
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
|
||||
assert.equal(
|
||||
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
|
||||
@@ -213,7 +213,7 @@ describe("pty-run", () => {
|
||||
});
|
||||
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
|
||||
// Through argv, never as a shell assignment typed into the pane: this is
|
||||
// also the path an AI Router's API key takes, and a typed line would be
|
||||
// also the path an AI Proxy's API key takes, and a typed line would be
|
||||
// rendered to every attached browser and land in the shell history.
|
||||
const at = respawn.indexOf("-e");
|
||||
assert.ok(at > -1, "expected a tmux -e environment flag");
|
||||
@@ -309,7 +309,7 @@ describe("pty-run", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("spawnRun starts a session on ITS router: Claude by --settings, Codex by -p", () => {
|
||||
it("spawnRun starts a session on ITS proxy: Claude by --settings, Codex by -p", () => {
|
||||
let newSessionArgv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
@@ -320,7 +320,7 @@ describe("pty-run", () => {
|
||||
if (args[0] === "new-session") newSessionArgv = args;
|
||||
return "";
|
||||
});
|
||||
// The router store and the files a spawn writes live under these homes.
|
||||
// The proxy store and the files a spawn writes live under these homes.
|
||||
const saved = ["CLAUDE_HOME", "CODEX_HOME", "DASHBOARD_DATA_DIR"].map((k) => [
|
||||
k,
|
||||
process.env[k],
|
||||
@@ -329,30 +329,30 @@ describe("pty-run", () => {
|
||||
process.env.CODEX_HOME = path.join(TMP, "codex");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
try {
|
||||
const { name } = require("../lib/ai-routers").createRouter({
|
||||
label: "Router",
|
||||
const { name } = require("../lib/ai-proxies").createProxy({
|
||||
label: "Proxy",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "M",
|
||||
});
|
||||
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", router: name, initialPrompt: "hi" });
|
||||
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", proxy: name, initialPrompt: "hi" });
|
||||
const flag = newSessionArgv.find((a) => a.startsWith("--settings="));
|
||||
assert.equal(flag, `--settings=${path.join(TMP, "data", "router-settings", `${name}.json`)}`);
|
||||
assert.equal(flag, `--settings=${path.join(TMP, "data", "proxy-settings", `${name}.json`)}`);
|
||||
assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value"));
|
||||
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").router, name);
|
||||
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").proxy, name);
|
||||
|
||||
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", router: name });
|
||||
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", proxy: name });
|
||||
const at = newSessionArgv.indexOf("-p");
|
||||
assert.deepEqual(newSessionArgv.slice(at, at + 2), ["-p", name]);
|
||||
// The key rides tmux's -e, never the command line.
|
||||
assert.ok(newSessionArgv.includes("AGENTHUB_ROUTER_KEY=sk-secret-value"));
|
||||
assert.ok(newSessionArgv.includes("AGENTHUB_PROXY_KEY=sk-secret-value"));
|
||||
|
||||
pty.spawnRun({ laneId: 53, cwd: "/tmp/repo" });
|
||||
assert.ok(!newSessionArgv.some((a) => a.startsWith("--settings=")));
|
||||
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").router, null);
|
||||
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").proxy, null);
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", router: "nope" }),
|
||||
(e) => e.code === "EBADROUTER"
|
||||
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", proxy: "nope" }),
|
||||
(e) => e.code === "EBADPROXY"
|
||||
);
|
||||
} finally {
|
||||
for (const [k, v] of saved) {
|
||||
|
||||
@@ -28,6 +28,19 @@ describe("tmux wrapper", () => {
|
||||
assert.equal(tmux.hasSession("agenthub-lane-1"), false);
|
||||
});
|
||||
|
||||
it("newSession names a command that exited at once instead of tmux's set-option error", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "new-session") return "";
|
||||
throw Object.assign(new Error("Command failed: tmux " + args.join(" ")), { status: 1 });
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
tmux.newSession({ name: "agenthub-lane-4", cwd: "/tmp/repo", argv: ["claude", "-p", "x"] }),
|
||||
(err) =>
|
||||
err.code === "EPANEEXITED" && /exited as soon as it started: claude$/.test(err.message)
|
||||
);
|
||||
});
|
||||
|
||||
it("newSession builds the correct argv", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
|
||||
Reference in New Issue
Block a user