Files
AgentHub/server/__tests__/proxy-apply.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

258 lines
9.4 KiB
JavaScript

/**
* @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 proxy out of a workspace's `settings.local.json`.
* @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-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");
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 proxies = require("../lib/ai-proxies");
const lanesLib = require("../lib/lanes");
const { db } = require("../db");
const {
resolveProxy,
resumeProxy,
proxyForSpawn,
refreshProfileFiles,
removeProfileFiles,
clearLaneProxies,
} = require("../lib/proxy-apply");
const settingsFile = (name) =>
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-proxies"),
process.env.CODEX_HOME,
process.env.DASHBOARD_DATA_DIR,
]) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function saveNineRouter() {
return proxies.createProxy({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
});
}
const spawnFor = (tool, name) => proxyForSpawn(tool, resolveProxy(tool, name));
describe("resolveProxy", () => {
it("reads null, empty and native as the vendor's own API", () => {
for (const name of [null, undefined, "", "native"])
assert.equal(resolveProxy("claude", name), null);
});
it("refuses a profile the store does not have", () => {
assert.throws(
() => resolveProxy("claude", "nope"),
(e) => e.code === "EBADPROXY"
);
});
it("refuses a proxy on a tool with no endpoint to point at one", () => {
const { name } = saveNineRouter();
assert.throws(
() => resolveProxy("antigravity", name),
(e) => e.code === "EUNSUPPORTED"
);
assert.equal(resolveProxy("antigravity", "native"), null);
});
});
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);
assert.deepEqual(argv, [`--settings=${settingsFile(name)}`]);
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"); // Claude Code appends /v1/messages
assert.equal(written.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
for (const k of [
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
]) {
assert.equal(written[k], "Highest", k);
}
assert.equal(fs.statSync(settingsFile(name)).mode & 0o777, 0o600);
});
it("leaves a blank key out instead of blanking the user's own setting", () => {
const { name } = proxies.createProxy({
label: "Local",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
model: "Highest",
});
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal("ANTHROPIC_AUTH_TOKEN" in written, false);
});
it("adds nothing for native", () => {
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
});
it("writes no modelPicker without behavesAs, so the user's own one still applies", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
assert.equal("modelPicker" in written, false);
});
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",
models: { haiku: "agent-fast", subagent: "claude-haiku-4-5-20251001" },
behavesAs: "claude-opus-5-5",
});
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
// A bare claude-* id is one Claude Code knows; mapping it would override
// its real handling.
assert.deepEqual(written.modelPicker, {
options: [
{ model: "agent", behavesAs: "claude-opus-5-5" },
{ model: "agent-fast", behavesAs: "claude-opus-5-5" },
],
});
});
});
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 });
fs.writeFileSync(base, 'approval_policy = "never"\n');
const { name } = saveNineRouter();
const { argv, env } = spawnFor("codex", name);
assert.deepEqual(argv, ["-p", name]);
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_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 } = proxies.createProxy({
label: "Local gateway",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
model: "Highest",
});
const { env } = spawnFor("codex", name);
assert.deepEqual(env, {});
assert.ok(!fs.readFileSync(layerFile(name), "utf8").includes("env_key"));
});
});
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);
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");
assert.equal(fs.existsSync(layerFile(name)), false);
});
it("a delete removes both files", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
spawnFor("codex", name);
removeProfileFiles(name);
assert.equal(fs.existsSync(settingsFile(name)), false);
assert.equal(fs.existsSync(layerFile(name)), false);
});
});
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-proxy-1",
mode: "interactive",
proxy: name,
});
assert.equal(resumeProxy("sess-proxy-1"), name);
proxies.removeProxy(name);
assert.equal(resumeProxy("sess-proxy-1"), null);
assert.equal(resumeProxy("sess-unknown"), null);
});
});
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 });
fs.writeFileSync(
file,
JSON.stringify({
permissions: { allow: ["Bash(ls)"] },
env: { ANTHROPIC_BASE_URL: "http://x", ANTHROPIC_AUTH_TOKEN: "sk", MY_VAR: "1" },
})
);
const lane = lanesLib.createLane({ title: "old", cwd, router_profile: "9router" });
clearLaneProxies();
const data = JSON.parse(fs.readFileSync(file, "utf8"));
assert.deepEqual(data.env, { MY_VAR: "1" });
assert.deepEqual(data.permissions, { allow: ["Bash(ls)"] });
assert.equal(
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
null
);
});
it("keeps the column of a workspace whose file it could not clean, to retry later", () => {
const cwd = path.join(SUITE_ROOT, "broken-lane");
const file = path.join(cwd, ".claude", "settings.local.json");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "{ not json");
const lane = lanesLib.createLane({ title: "broken", cwd, router_profile: "9router" });
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,
"9router"
);
});
});