5fe5d51b1b
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
258 lines
9.4 KiB
JavaScript
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"
|
|
);
|
|
});
|
|
});
|