/** * @file Tests for starting one session on an AI Proxy profile — Claude Code's * `--settings=` env file and the Codex `.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ĩ */ 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" ); }); });