/** * @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ĩ */ 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", }); }); });