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:
+1
-1
@@ -701,7 +701,7 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
| `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) |
|
||||
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`) |
|
||||
| `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` |
|
||||
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; a session's AI Router layer is written into the current home at its spawn, so nothing is re-written here |
|
||||
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; a session's AI Proxy layer is written into the current home at its spawn, so nothing is re-written here |
|
||||
| `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) |
|
||||
| `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) |
|
||||
| `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone |
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+14
-6
@@ -648,7 +648,9 @@ try {
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run();
|
||||
}
|
||||
// Which AI Router profile this lane runs against (see server/lib/ai-routers.js).
|
||||
// Which AI Proxy (then called AI Router) profile this lane ran against. Legacy:
|
||||
// a proxy is a session's choice now, and proxy-apply.js's clearLaneProxies
|
||||
// empties this column at every start.
|
||||
// Nullable, and null means the same thing as 'native': the vendor's own API.
|
||||
// Set at creation only — like `tool`, it is absent from lanes.js's PATCHABLE,
|
||||
// because the router is already written into the lane's config on disk and a
|
||||
@@ -687,13 +689,19 @@ try {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN worktree INTEGER NOT NULL DEFAULT 0").run();
|
||||
}
|
||||
|
||||
// Migrate: add `router` to dashboard_runs — the AI Router profile the run was
|
||||
// started on (NULL = native). A router is chosen per session now, and a resume
|
||||
// that does not name one goes back to the router its session last ran on.
|
||||
// Migrate: add `proxy` to dashboard_runs — the AI Proxy profile the run was
|
||||
// started on (NULL = native). A proxy is chosen per session, and a resume that
|
||||
// does not name one goes back to the proxy its session last ran on. The column
|
||||
// was `router` before the AI Router → AI Proxy rename; it is renamed in place.
|
||||
try {
|
||||
db.prepare("SELECT router FROM dashboard_runs LIMIT 1").get();
|
||||
db.prepare("SELECT proxy FROM dashboard_runs LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN router TEXT").run();
|
||||
try {
|
||||
db.prepare("SELECT router FROM dashboard_runs LIMIT 1").get();
|
||||
db.prepare("ALTER TABLE dashboard_runs RENAME COLUMN router TO proxy").run();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN proxy TEXT").run();
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
|
||||
|
||||
+6
-5
@@ -82,7 +82,7 @@ const importRouter = require("./routes/import");
|
||||
const updatesRouter = require("./routes/updates");
|
||||
const ccConfigRouter = require("./routes/cc-config");
|
||||
const codexConfigRouter = require("./routes/codex-config");
|
||||
const aiRoutersRouter = require("./routes/ai-routers");
|
||||
const aiProxiesRouter = require("./routes/ai-proxies");
|
||||
const gitCredentialsRouter = require("./routes/git-credentials");
|
||||
const aiToolsRouter = require("./routes/ai-tools");
|
||||
const runRouter = require("./routes/run");
|
||||
@@ -133,7 +133,8 @@ function createApp() {
|
||||
app.use("/api/updates", updatesRouter);
|
||||
app.use("/api/cc-config", ccConfigRouter);
|
||||
app.use("/api/codex-config", codexConfigRouter);
|
||||
app.use("/api/ai-routers", aiRoutersRouter);
|
||||
// `/api/ai-routers` is the pre-rename path, kept for a client older than it.
|
||||
app.use(["/api/ai-proxies", "/api/ai-routers"], aiProxiesRouter);
|
||||
app.use("/api/git-credentials", gitCredentialsRouter);
|
||||
app.use("/api/ai-tools", aiToolsRouter);
|
||||
app.use("/api/run", runRouter);
|
||||
@@ -680,12 +681,12 @@ function startBackgroundServices() {
|
||||
} catch (err) {
|
||||
console.warn("dashboard-runs reconciliation failed:", err.message);
|
||||
}
|
||||
// A router is chosen per session now: take the old per-workspace one out of
|
||||
// A proxy is chosen per session now: take the old per-workspace one out of
|
||||
// each workspace's settings.local.json (a no-op once done).
|
||||
try {
|
||||
require("./lib/router-apply").clearLaneRouters();
|
||||
require("./lib/proxy-apply").clearLaneProxies();
|
||||
} catch (err) {
|
||||
console.warn("clearing workspace routers failed:", err.message);
|
||||
console.warn("clearing workspace proxies failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
/**
|
||||
* @file ai-routers.js
|
||||
* @description Named AI Router profiles — an endpoint (9Router, OpenRouter, a
|
||||
* @file ai-proxies.js
|
||||
* @description Named AI Proxy profiles — an endpoint (9Router, OpenRouter, a
|
||||
* self-hosted gateway) plus the model names to use against it, saved once and
|
||||
* attached to a lane when that lane is created.
|
||||
* chosen by a new session (`proxy-apply.js`).
|
||||
*
|
||||
* A profile is exactly what the two CLIs need to talk to a router and nothing
|
||||
* A profile is exactly what the two CLIs need to talk to a proxy and nothing
|
||||
* more: `baseUrl`, `apiKey`, a default `model`, and one slot per tier mirroring
|
||||
* the env keys Claude Code reads. Per-tier slots because a router may expose a
|
||||
* the env keys Claude Code reads. Per-tier slots because a proxy may expose a
|
||||
* different id per tier — and the `model` field fills every one of them,
|
||||
* because the common case is a single symbolic name for everything.
|
||||
*
|
||||
* Profiles are stored one JSON file per profile under
|
||||
* `<CLAUDE_HOME>/agenthub-routers/` (dir 0700, files 0600), the same shape and
|
||||
* the same reasoning as `cc-profiles.js`: they hold a live API key, so **no
|
||||
* route ever returns key material** — `listRouters` reports `hasKey` and
|
||||
* nothing else about it.
|
||||
* `baseUrl` is the proxy's ROOT, without `/v1`: Claude Code appends
|
||||
* `/v1/messages` to it, and the Codex layer appends `/v1` itself. A `/v1`
|
||||
* typed (or stored by an older build) is dropped by `normalizeBaseUrl`.
|
||||
*
|
||||
* `native` is a built-in profile rather than a branch in the caller: it carries
|
||||
* empty connection fields and the vendor's own model names, so "switch back to
|
||||
* native" is the same single write path as "switch to a router". It cannot be
|
||||
* Profiles are stored one JSON file per profile under
|
||||
* `<CLAUDE_HOME>/agenthub-proxies/` (dir 0700, files 0600; the pre-rename
|
||||
* `agenthub-routers/` is moved there on first read, see `proxiesDir`), the same
|
||||
* shape and the same reasoning as `cc-profiles.js`: they hold a live API key,
|
||||
* so **no route ever returns key material** — `listProxies` reports `hasKey`
|
||||
* and nothing else about it.
|
||||
*
|
||||
* `native` is a built-in profile rather than a branch in the caller: it has no
|
||||
* endpoint and no model of its own (the session's model picker decides), so
|
||||
* "use the vendor's own API" is a choice like any profile. It cannot be
|
||||
* edited, removed, or shadowed by a saved profile.
|
||||
*
|
||||
* `testRouter` is the one thing here that talks to the network: the smallest
|
||||
* real request a claude lane would make, so "saved" and "actually works" stop
|
||||
* being the same claim. It is deliberately NOT a generic proxy — see the note
|
||||
* on its stored-key fallback.
|
||||
* `testProxy` is the one thing here that talks to the network: the smallest
|
||||
* real request each CLI would make, so "saved" and "actually works" stop
|
||||
* being the same claim. It is deliberately NOT a generic forwarder — see the
|
||||
* note on its stored-key fallback.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -50,10 +55,8 @@ const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
const BUILTIN_NAME = "native";
|
||||
|
||||
/**
|
||||
* The vendor's own defaults, used when a lane is set back to native. Empty
|
||||
* connection fields rather than absent ones: the native reference settings.json
|
||||
* sets both to "", and an absent key would let a user-level router base URL
|
||||
* leak into a lane that asked for native.
|
||||
* The vendor's own API. Never written anywhere (`resolveProxy` answers null for
|
||||
* it), so it carries no model ids — the session's model picker picks.
|
||||
*/
|
||||
const NATIVE = Object.freeze({
|
||||
name: BUILTIN_NAME,
|
||||
@@ -61,14 +64,8 @@ const NATIVE = Object.freeze({
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "claude-sonnet-5",
|
||||
models: Object.freeze({
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
}),
|
||||
model: "",
|
||||
models: Object.freeze({}),
|
||||
});
|
||||
|
||||
function err(code, message) {
|
||||
@@ -77,15 +74,34 @@ function err(code, message) {
|
||||
return e;
|
||||
}
|
||||
|
||||
function routersDir() {
|
||||
return path.join(getClaudeHome(), "agenthub-routers");
|
||||
/**
|
||||
* The profile store. The first read after the rename moves the old
|
||||
* `agenthub-routers/` here and leaves a symlink at the old name, so an older
|
||||
* build on the same CLAUDE_HOME still finds its profiles.
|
||||
*/
|
||||
function proxiesDir() {
|
||||
const dir = path.join(getClaudeHome(), "agenthub-proxies");
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
const legacy = path.join(getClaudeHome(), "agenthub-routers");
|
||||
try {
|
||||
if (fs.lstatSync(legacy).isSymbolicLink()) return dir;
|
||||
fs.renameSync(legacy, dir);
|
||||
} catch {
|
||||
return dir; // no legacy store (or it could not be moved — read it next time)
|
||||
}
|
||||
try {
|
||||
fs.symlinkSync("agenthub-proxies", legacy, "dir");
|
||||
} catch {
|
||||
/* best effort — the profiles themselves are already in place */
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function routerFile(name) {
|
||||
function proxyFile(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
||||
}
|
||||
return path.join(routersDir(), `${name}.json`);
|
||||
return path.join(proxiesDir(), `${name}.json`);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
@@ -119,7 +135,7 @@ function slugify(text) {
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
return NAME_RE.test(base) ? base : "router";
|
||||
return NAME_RE.test(base) ? base : "proxy";
|
||||
}
|
||||
|
||||
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
|
||||
@@ -127,7 +143,7 @@ function freeName(base) {
|
||||
let name = base;
|
||||
for (
|
||||
let n = 2;
|
||||
name === BUILTIN_NAME || fs.existsSync(path.join(routersDir(), `${name}.json`));
|
||||
name === BUILTIN_NAME || fs.existsSync(path.join(proxiesDir(), `${name}.json`));
|
||||
) {
|
||||
name = `${base}-${n}`;
|
||||
n += 1;
|
||||
@@ -147,9 +163,10 @@ function normalizeLabel(label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A router endpoint is an http(s) URL and nothing else. A `file:` or
|
||||
* `javascript:` base URL is never a router — it is a mistake, or an attempt to
|
||||
* point a CLI somewhere it should not go.
|
||||
* A proxy endpoint is an http(s) URL and nothing else. A `file:` or
|
||||
* `javascript:` base URL is never a proxy — it is a mistake, or an attempt to
|
||||
* point a CLI somewhere it should not go. A trailing `/v1` is dropped: the
|
||||
* root is stored, and each CLI's path is added where it is used.
|
||||
*/
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
|
||||
@@ -163,10 +180,10 @@ function normalizeBaseUrl(baseUrl) {
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw err("EBADURL", "baseUrl must be http:// or https://");
|
||||
}
|
||||
return raw.replace(/\/+$/, "");
|
||||
return raw.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
}
|
||||
|
||||
/** A model name is free text — a router names its own models — but not blank. */
|
||||
/** A model name is free text — a proxy names its own models — but not blank. */
|
||||
function normalizeModel(model) {
|
||||
const clean = typeof model === "string" ? model.trim() : "";
|
||||
if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) {
|
||||
@@ -190,10 +207,10 @@ function normalizeModels(model, models) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A known Claude model id that Claude Code should treat the router's own model
|
||||
* A known Claude model id that Claude Code should treat the proxy's own model
|
||||
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
|
||||
* The user picks it, never AgentHub: it also sets the context window Claude
|
||||
* Code compacts at, and a router may route a name to a non-Claude model.
|
||||
* Code compacts at, and a proxy may route a name to a non-Claude model.
|
||||
*/
|
||||
function normalizeBehavesAs(value) {
|
||||
const clean = typeof value === "string" ? value.trim() : null;
|
||||
@@ -210,10 +227,17 @@ function assertNotBuiltin(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one stored profile (key included), or null when it does not exist. */
|
||||
function readRouter(name) {
|
||||
const stored = readJson(routerFile(name));
|
||||
return stored && typeof stored === "object" ? stored : null;
|
||||
/**
|
||||
* Read one stored profile (key included), or null when it does not exist. A
|
||||
* base URL an older build stored with `/v1` comes back as the root.
|
||||
*/
|
||||
function readProxy(name) {
|
||||
const stored = readJson(proxyFile(name));
|
||||
if (!stored || typeof stored !== "object") return null;
|
||||
if (typeof stored.baseUrl === "string") {
|
||||
stored.baseUrl = stored.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** The browser-safe view: everything except the key, plus whether one is set. */
|
||||
@@ -226,11 +250,11 @@ function publicView(profile) {
|
||||
* Every saved profile, with the built-in native one first.
|
||||
* @returns `{ items }` — key material is never included.
|
||||
*/
|
||||
function listRouters() {
|
||||
function listProxies() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
.readdirSync(routersDir())
|
||||
.readdirSync(proxiesDir())
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5));
|
||||
} catch {
|
||||
@@ -242,7 +266,7 @@ function listRouters() {
|
||||
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
|
||||
let stored;
|
||||
try {
|
||||
stored = readRouter(name);
|
||||
stored = readProxy(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
}
|
||||
@@ -254,12 +278,12 @@ function listRouters() {
|
||||
|
||||
/**
|
||||
* One profile INCLUDING its key — for the server's own writers only. No route
|
||||
* may return this shape; `listRouters` is what the browser sees.
|
||||
* may return this shape; `listProxies` is what the browser sees.
|
||||
*/
|
||||
function getRouter(name) {
|
||||
function getProxy(name) {
|
||||
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
|
||||
const stored = readRouter(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
const stored = readProxy(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
||||
return { ...stored, name, builtin: false };
|
||||
}
|
||||
|
||||
@@ -268,7 +292,7 @@ function getRouter(name) {
|
||||
* models?:Record<string,string>, behavesAs?:string}} input
|
||||
* @returns the browser-safe view of what was saved
|
||||
*/
|
||||
function createRouter(input = {}) {
|
||||
function createProxy(input = {}) {
|
||||
const label = normalizeLabel(input.label);
|
||||
const slug = slugify(label);
|
||||
assertNotBuiltin(slug);
|
||||
@@ -285,19 +309,20 @@ function createRouter(input = {}) {
|
||||
};
|
||||
|
||||
const name = freeName(slug);
|
||||
writeJson(routerFile(name), profile);
|
||||
writeJson(proxyFile(name), profile);
|
||||
return publicView({ ...profile, name, builtin: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge changes into a saved profile. The `name` never changes — a lane stores
|
||||
* it, and renaming would orphan that lane. An omitted `apiKey` keeps the stored
|
||||
* one: the edit form cannot show a key, so it cannot send one back either.
|
||||
* Merge changes into a saved profile. The `name` never changes — a session's
|
||||
* run row stores it for a resume, and renaming would orphan that. An omitted
|
||||
* `apiKey` keeps the stored one: the edit form cannot show a key, so it cannot
|
||||
* send one back either.
|
||||
*/
|
||||
function updateRouter(name, changes = {}) {
|
||||
function updateProxy(name, changes = {}) {
|
||||
assertNotBuiltin(name);
|
||||
const stored = readRouter(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
const stored = readProxy(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
||||
|
||||
const model = changes.model === undefined ? stored.model : normalizeModel(changes.model);
|
||||
const next = {
|
||||
@@ -320,14 +345,14 @@ function updateRouter(name, changes = {}) {
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeJson(routerFile(name), next);
|
||||
writeJson(proxyFile(name), next);
|
||||
return publicView({ ...next, name, builtin: false });
|
||||
}
|
||||
|
||||
function removeRouter(name) {
|
||||
function removeProxy(name) {
|
||||
assertNotBuiltin(name);
|
||||
const file = routerFile(name);
|
||||
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
const file = proxyFile(name);
|
||||
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
||||
fs.rmSync(file);
|
||||
return { removed: name };
|
||||
}
|
||||
@@ -335,59 +360,21 @@ function removeRouter(name) {
|
||||
/** How long a connection test waits before calling the endpoint dead. */
|
||||
const TEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Enough of a failing router's own words to be actionable, not enough to flood. */
|
||||
/** Enough of a failing proxy's own words to be actionable, not enough to flood. */
|
||||
const TEST_BODY_MAX = 300;
|
||||
|
||||
/**
|
||||
* Probe a router endpoint with the smallest real request a claude lane makes:
|
||||
* one Anthropic-style message capped at a single output token.
|
||||
*
|
||||
* Deliberately the CLAUDE wire shape and not Codex's `responses` one. A profile
|
||||
* is not tool-specific, so a test has to pick; `/v1/messages` is what a claude
|
||||
* lane's eight env keys point at, and it is the path a router has to serve for
|
||||
* this feature to work at all.
|
||||
*
|
||||
* **The stored key is only ever sent to the endpoint it was stored against.**
|
||||
* A blank `apiKey` falls back to the saved profile named by `name` — but only
|
||||
* when the submitted `baseUrl` still matches the saved one. Without that check
|
||||
* this route would be a way to make the server POST a saved API key to any URL
|
||||
* a browser names, which is exactly the leak `listRouters` exists to prevent.
|
||||
*
|
||||
* A router that answers is a success even when it answers with an error: the
|
||||
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad INPUT
|
||||
* throws.
|
||||
*
|
||||
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
|
||||
* @returns {Promise<{ok:boolean, status:number|null, message:string}>}
|
||||
*/
|
||||
async function testRouter(input = {}) {
|
||||
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
||||
const model = normalizeModel(input.model);
|
||||
|
||||
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
|
||||
if (!apiKey && input.name) {
|
||||
const stored = readRouter(input.name);
|
||||
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
|
||||
}
|
||||
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
|
||||
|
||||
/** POST `body` to `url`; the outcome is a result, never a throw. */
|
||||
async function probe(url, apiKey, headers, body, model) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}/v1/messages`, {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
// Both, because routers disagree on which one they read, and it is the
|
||||
// same key going to the same endpoint either way.
|
||||
"x-api-key": apiKey,
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -403,22 +390,74 @@ async function testRouter(input = {}) {
|
||||
message: e.name === "TimeoutError" ? "timed out" : (e.cause && e.cause.message) || e.message,
|
||||
};
|
||||
}
|
||||
|
||||
const body = await res.text().catch(() => "");
|
||||
const text = await res.text().catch(() => "");
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
message: res.ok ? model : body.slice(0, TEST_BODY_MAX) || res.statusText,
|
||||
message: res.ok ? model : text.slice(0, TEST_BODY_MAX) || res.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a proxy endpoint with the smallest real request each CLI makes, in
|
||||
* parallel: Claude Code's Anthropic message (`/v1/messages`, one output token)
|
||||
* and Codex's OpenAI Responses call (`/v1/responses`, the 16-token minimum). A
|
||||
* profile is not tool-specific, so a proxy that serves only one wire shape
|
||||
* shows up as one green line and one red.
|
||||
*
|
||||
* **The stored key is only ever sent to the endpoint it was stored against.**
|
||||
* A blank `apiKey` falls back to the saved profile named by `name` — but only
|
||||
* when the submitted `baseUrl` still matches the saved one. Without that check
|
||||
* this route would be a way to make the server POST a saved API key to any URL
|
||||
* a browser names, which is exactly the leak `listProxies` exists to prevent.
|
||||
*
|
||||
* A proxy that answers is a success even when it answers with an error: each
|
||||
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad
|
||||
* INPUT throws.
|
||||
*
|
||||
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
|
||||
* @returns {Promise<{claude: TestResult, codex: TestResult}>} where TestResult
|
||||
* is `{ok:boolean, status:number|null, message:string}`
|
||||
*/
|
||||
async function testProxy(input = {}) {
|
||||
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
||||
const model = normalizeModel(input.model);
|
||||
|
||||
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
|
||||
if (!apiKey && input.name) {
|
||||
const stored = readProxy(input.name);
|
||||
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
|
||||
}
|
||||
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
|
||||
|
||||
const [claude, codex] = await Promise.all([
|
||||
probe(
|
||||
`${baseUrl}/v1/messages`,
|
||||
apiKey,
|
||||
// `x-api-key` beside the Bearer header, because proxies disagree on which
|
||||
// one they read, and it is the same key going to the same endpoint.
|
||||
{ "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
|
||||
{ model, max_tokens: 1, messages: [{ role: "user", content: "ping" }] },
|
||||
model
|
||||
),
|
||||
probe(
|
||||
`${baseUrl}/v1/responses`,
|
||||
apiKey,
|
||||
{},
|
||||
{ model, input: "ping", max_output_tokens: 16 },
|
||||
model
|
||||
),
|
||||
]);
|
||||
return { claude, codex };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRouters,
|
||||
getRouter,
|
||||
createRouter,
|
||||
updateRouter,
|
||||
removeRouter,
|
||||
testRouter,
|
||||
listProxies,
|
||||
getProxy,
|
||||
createProxy,
|
||||
updateProxy,
|
||||
removeProxy,
|
||||
testProxy,
|
||||
MODEL_SLOTS,
|
||||
BUILTIN_NAME,
|
||||
NATIVE,
|
||||
@@ -21,7 +21,7 @@ const CMD_RE = /^[A-Za-z0-9._+-]+$/;
|
||||
const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const LAUNCHERS = new Set(["claude", "codex"]);
|
||||
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
|
||||
const ROUTER_CAPABLE = new Set(["claude", "codex"]);
|
||||
const PROXY_CAPABLE = new Set(["claude", "codex"]);
|
||||
const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
||||
// Files shipped in a release (built-in, or synced from the newest release) are
|
||||
// trusted to name a launcher and an install/update; a custom file is not.
|
||||
@@ -417,7 +417,7 @@ function view() {
|
||||
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
||||
resume: !!t.resume,
|
||||
bypass: !!(t.bypass || t.bypassEnv),
|
||||
routerCapable: ROUTER_CAPABLE.has(t.id),
|
||||
proxyCapable: PROXY_CAPABLE.has(t.id),
|
||||
// claude/codex take a model through their own argv builders.
|
||||
modelFlag: Boolean(t.model || t.launcher),
|
||||
defaultModel: state.models[t.id] || null,
|
||||
@@ -458,7 +458,7 @@ module.exports = {
|
||||
which,
|
||||
spawnTarget,
|
||||
siteOf,
|
||||
ROUTER_CAPABLE,
|
||||
PROXY_CAPABLE,
|
||||
CONFIG_SECRET_RE,
|
||||
__setCatalogDirs,
|
||||
};
|
||||
|
||||
@@ -572,7 +572,7 @@ function patchSettings(args) {
|
||||
}
|
||||
|
||||
// A patch that changes nothing writes nothing — and therefore backs up
|
||||
// nothing. Re-applying the same AI Router profile to a lane is the common
|
||||
// nothing. Re-applying the same AI Proxy profile to a lane is the common
|
||||
// case (every re-attach sends the same eight env keys), and an unconditional
|
||||
// backup turned that into a `.bak` per apply piling up inside the user's
|
||||
// project. `changed` still lists the keys the caller asked for: they ARE the
|
||||
|
||||
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO dashboard_runs (
|
||||
id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
||||
lane_id, tool, worktree, router
|
||||
lane_id, tool, worktree, proxy
|
||||
) VALUES (
|
||||
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
||||
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
||||
@lane_id, @tool, @worktree, @router
|
||||
@lane_id, @tool, @worktree, @proxy
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
|
||||
|
||||
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code,
|
||||
started_at, ended_at, lane_id, tool, worktree, router`;
|
||||
started_at, ended_at, lane_id, tool, worktree, proxy`;
|
||||
|
||||
const listStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
@@ -123,7 +123,7 @@ function recordRun(handle) {
|
||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
||||
worktree: handle.worktree ? 1 : 0,
|
||||
router: handle.router || null,
|
||||
proxy: handle.proxy || null,
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort by design (a live run must never fail because sqlite did),
|
||||
@@ -181,19 +181,19 @@ function getRun(id) {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionRouterStmt = db.prepare(`
|
||||
SELECT router FROM dashboard_runs
|
||||
const sessionProxyStmt = db.prepare(`
|
||||
SELECT proxy FROM dashboard_runs
|
||||
WHERE session_id = @sessionId OR resume_session_id = @sessionId
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
`);
|
||||
|
||||
/**
|
||||
* The AI Router the newest run holding `sessionId` started on (null = native,
|
||||
* or no run known) — what a resume that names no router goes back to.
|
||||
* The AI Proxy the newest run holding `sessionId` started on (null = native,
|
||||
* or no run known) — what a resume that names no proxy goes back to.
|
||||
*/
|
||||
function routerForSession(sessionId) {
|
||||
function proxyForSession(sessionId) {
|
||||
try {
|
||||
return sessionRouterStmt.get({ sessionId })?.router || null;
|
||||
return sessionProxyStmt.get({ sessionId })?.proxy || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -308,7 +308,7 @@ module.exports = {
|
||||
getRun,
|
||||
forgetShellRun,
|
||||
reconcileOrphans,
|
||||
routerForSession,
|
||||
proxyForSession,
|
||||
laneForSession,
|
||||
claimToolSessions,
|
||||
};
|
||||
|
||||
@@ -233,7 +233,10 @@ function importExportBundle(db, bundle) {
|
||||
// Session-independent, config-like tables: restore by natural PK, never
|
||||
// overwriting a row the target machine already has.
|
||||
for (const r of Array.isArray(bundle.dashboard_runs) ? bundle.dashboard_runs : []) {
|
||||
if (r && r.id && insert.dashboard_runs(r).changes > 0) counters.dashboard_runs++;
|
||||
// `router` is the pre-rename name of `proxy`, in a bundle an older build exported.
|
||||
const row =
|
||||
r && r.router !== undefined && r.proxy === undefined ? { ...r, proxy: r.router } : r;
|
||||
if (row && row.id && insert.dashboard_runs(row).changes > 0) counters.dashboard_runs++;
|
||||
}
|
||||
for (const r of Array.isArray(bundle.alert_rules) ? bundle.alert_rules : []) {
|
||||
if (r && r.id && insert.alert_rules(r).changes > 0) counters.alert_rules++;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* One JSON file under `<CLAUDE_HOME>/agenthub-git-credentials/` (dir 0700,
|
||||
* file 0600, written through a temp file), one record per protocol + host,
|
||||
* shared by everyone using this AgentHub. Like the AI Router store, **no route
|
||||
* shared by everyone using this AgentHub. Like the AI Proxy store, **no route
|
||||
* ever returns a password** — `list` reports `hasToken`.
|
||||
*
|
||||
* `gitConfigFor` is how a credential reaches git: `-c` args that make OUR
|
||||
|
||||
@@ -77,8 +77,8 @@ const VALID_RANGES = new Set(["work", "branch", "commit"]);
|
||||
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
|
||||
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
|
||||
/** The file whose CONTENT this module never returns, at any rev and at ANY
|
||||
* depth: `settings.local.json` is where router-apply.js writes a lane's AI
|
||||
* Router API key, and key material never leaves the server. Any depth,
|
||||
* depth: `settings.local.json` is where AgentHub once wrote a lane's AI
|
||||
* Proxy API key, and key material never leaves the server. Any depth,
|
||||
* because a lane at the repo root sees a subdirectory lane's key as
|
||||
* `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive
|
||||
* filesystem (macOS) opens it under any casing. */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* The guards are the READ path's own (git-diff.js), so the editor can never
|
||||
* write where the viewer could not read: a lexical `lanePath`, a realpath
|
||||
* confined to the lane (no `..`, no symlink out), and no `settings.local.json`
|
||||
* — the lane router's key file — at any depth or through any symlink. On top:
|
||||
* — where a lane's proxy key once lived — at any depth or through any symlink. On top:
|
||||
* only an EXISTING, textual file within the viewer's size cap, and only over
|
||||
* the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save
|
||||
* never silently overwrites what the agent wrote in the meantime (ESTALE).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file router-apply.js
|
||||
* @description Starts ONE session on an AI Router profile (see `ai-routers.js`).
|
||||
* A router is chosen per new session, not per workspace, so nothing here writes
|
||||
* @file proxy-apply.js
|
||||
* @description Starts ONE session on an AI Proxy profile (see `ai-proxies.js`).
|
||||
* A proxy is chosen per new session, not per workspace, so nothing here writes
|
||||
* into the user's project.
|
||||
*
|
||||
* The two CLIs are configured in different places, and the difference is
|
||||
@@ -9,24 +9,27 @@
|
||||
*
|
||||
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
|
||||
* profile's eight `env` keys (plus `modelPicker` rows when the profile
|
||||
* sets `behavesAs`), `<data dir>/router-settings/<name>.json`
|
||||
* sets `behavesAs`), `<data dir>/proxy-settings/<name>.json`
|
||||
* (0600). The command-line layer outranks the project-local and user
|
||||
* settings (verified on 2.1.286), so two sessions of one workspace can run
|
||||
* on two routers, and the key stays out of argv — only the path is there.
|
||||
* on two proxies, and the key stays out of argv — only the path is there.
|
||||
*
|
||||
* - **Codex** has no project-level config at all. What it does have is
|
||||
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of the
|
||||
* base. So a profile becomes a layer file, the base is never touched, and
|
||||
* the key reaches the pane through tmux's own `-e` under the variable the
|
||||
* layer's `env_key` names, never as typed text.
|
||||
* layer's `env_key` names, never as typed text. Its `base_url` is the
|
||||
* profile's root plus `/v1` (Codex appends `/responses`); Claude Code's
|
||||
* `ANTHROPIC_BASE_URL` is the bare root (it appends `/v1/messages`).
|
||||
*
|
||||
* Both files are (re)written at every spawn, so an edited profile reaches the
|
||||
* next session and a moved CODEX_HOME needs no repair. `refreshProfileFiles`
|
||||
* rewrites them on an edit (a live Claude session reads its file again);
|
||||
* `removeProfileFiles` deletes them with the profile.
|
||||
*
|
||||
* Workspaces once carried a router of their own in `lanes.router_profile` and
|
||||
* `<cwd>/.claude/settings.local.json`; `clearLaneRouters` takes that out once.
|
||||
* Workspaces once carried a proxy (then called a router) of their own in
|
||||
* `lanes.router_profile` and `<cwd>/.claude/settings.local.json`;
|
||||
* `clearLaneProxies` takes that out once.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -34,19 +37,19 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const routers = require("./ai-routers");
|
||||
const proxies = require("./ai-proxies");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { ROUTER_CAPABLE } = require("./ai-tools");
|
||||
const { PROXY_CAPABLE } = require("./ai-tools");
|
||||
|
||||
/**
|
||||
* The env var Codex is told to read the router key from. One fixed name rather
|
||||
* The env var Codex is told to read the proxy key from. One fixed name rather
|
||||
* than one per profile: a Codex pane runs exactly one profile, so a second name
|
||||
* would only create the chance of the wrong one being set.
|
||||
*/
|
||||
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
|
||||
const CODEX_KEY_ENV = "AGENTHUB_PROXY_KEY";
|
||||
|
||||
/** The eight env keys a router sets for Claude Code. */
|
||||
/** The eight env keys a proxy sets for Claude Code. */
|
||||
function envForProfile(profile) {
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||
@@ -61,7 +64,7 @@ function envForProfile(profile) {
|
||||
}
|
||||
|
||||
function claudeSettingsPath(name) {
|
||||
return path.join(getDataDir(), "router-settings", `${name}.json`);
|
||||
return path.join(getDataDir(), "proxy-settings", `${name}.json`);
|
||||
}
|
||||
|
||||
function codexLayerPath(name) {
|
||||
@@ -75,7 +78,7 @@ function writeSecret(file, body) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `modelPicker` rows telling Claude Code which known model each of the router's
|
||||
* `modelPicker` rows telling Claude Code which known model each of the proxy's
|
||||
* own names behaves as — without one, a name like 9Router's `agent` draws an
|
||||
* "isn't described by this version's model catalog" warning and a 200k
|
||||
* compact window (issue #56). Only when the profile sets `behavesAs`: this
|
||||
@@ -100,7 +103,7 @@ function writeClaudeSettings(profile) {
|
||||
return file;
|
||||
}
|
||||
|
||||
/** TOML string literal — router names and URLs are validated, quotes are not free. */
|
||||
/** TOML string literal — proxy names and URLs are validated, quotes are not free. */
|
||||
function tomlString(value) {
|
||||
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
@@ -112,14 +115,14 @@ function tomlString(value) {
|
||||
function writeCodexLayer(profile) {
|
||||
const file = codexLayerPath(profile.name);
|
||||
const body =
|
||||
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
|
||||
`# Written by AgentHub — AI Proxy profile ${profile.label}.\n` +
|
||||
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
||||
`model = ${tomlString(profile.model)}\n` +
|
||||
`model_provider = ${tomlString(profile.name)}\n\n` +
|
||||
`[model_providers.${profile.name}]\n` +
|
||||
`name = ${tomlString(profile.label)}\n` +
|
||||
`base_url = ${tomlString(profile.baseUrl)}\n` +
|
||||
// `env_key` only when there is a key to name. A local router (the 9Router
|
||||
`base_url = ${tomlString(`${profile.baseUrl}/v1`)}\n` +
|
||||
// `env_key` only when there is a key to name. A local proxy (the 9Router
|
||||
// reference config is exactly this) needs none, and naming a variable that
|
||||
// is never set makes Codex refuse to start.
|
||||
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
||||
@@ -133,26 +136,26 @@ function writeCodexLayer(profile) {
|
||||
/**
|
||||
* The profile a new session asked for, checked against its tool. `null`,
|
||||
* `""` and `"native"` mean the vendor's own API and resolve to null.
|
||||
* @throws EBADROUTER for a profile the store does not have, EUNSUPPORTED for a
|
||||
* router on a tool that has no endpoint to point at one.
|
||||
* @throws EBADPROXY for a profile the store does not have, EUNSUPPORTED for a
|
||||
* proxy on a tool that has no endpoint to point at one.
|
||||
*/
|
||||
function resolveRouter(tool, name) {
|
||||
if (name == null || name === "" || name === routers.BUILTIN_NAME) return null;
|
||||
function resolveProxy(tool, name) {
|
||||
if (name == null || name === "" || name === proxies.BUILTIN_NAME) return null;
|
||||
if (typeof name !== "string") {
|
||||
throw Object.assign(new Error("router must be a profile name"), { code: "EBADROUTER" });
|
||||
throw Object.assign(new Error("proxy must be a profile name"), { code: "EBADPROXY" });
|
||||
}
|
||||
let profile;
|
||||
try {
|
||||
profile = routers.getRouter(name);
|
||||
profile = proxies.getProxy(name);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOTFOUND") throw e;
|
||||
throw Object.assign(new Error(`no AI Router profile named "${name}"`), {
|
||||
code: "EBADROUTER",
|
||||
throw Object.assign(new Error(`no AI Proxy profile named "${name}"`), {
|
||||
code: "EBADPROXY",
|
||||
});
|
||||
}
|
||||
if (profile.builtin) return null;
|
||||
if (!ROUTER_CAPABLE.has(tool)) {
|
||||
throw Object.assign(new Error(`a ${tool} session cannot use an AI Router`), {
|
||||
if (!PROXY_CAPABLE.has(tool)) {
|
||||
throw Object.assign(new Error(`a ${tool} session cannot use an AI Proxy`), {
|
||||
code: "EUNSUPPORTED",
|
||||
});
|
||||
}
|
||||
@@ -160,15 +163,15 @@ function resolveRouter(tool, name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The router a resume that names none goes back to: the one its session last
|
||||
* The proxy a resume that names none goes back to: the one its session last
|
||||
* ran on, while that profile still exists — a deleted one resumes on native.
|
||||
*/
|
||||
function resumeRouter(sessionId) {
|
||||
function resumeProxy(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const name = require("./dashboard-runs").routerForSession(sessionId);
|
||||
const name = require("./dashboard-runs").proxyForSession(sessionId);
|
||||
if (!name) return null;
|
||||
try {
|
||||
routers.getRouter(name);
|
||||
proxies.getProxy(name);
|
||||
return name;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -176,11 +179,11 @@ function resumeRouter(sessionId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Argv and env one spawn needs for `profile` (from `resolveRouter`): Claude's
|
||||
* Argv and env one spawn needs for `profile` (from `resolveProxy`): Claude's
|
||||
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
|
||||
* @returns {{argv: string[], env: Record<string,string>}}
|
||||
*/
|
||||
function routerForSpawn(tool, profile) {
|
||||
function proxyForSpawn(tool, profile) {
|
||||
if (!profile) return { argv: [], env: {} };
|
||||
if (tool === "codex") {
|
||||
writeCodexLayer(profile);
|
||||
@@ -199,18 +202,19 @@ function routerForSpawn(tool, profile) {
|
||||
* do not exist yet are written at the next spawn.
|
||||
*/
|
||||
function refreshProfileFiles(name) {
|
||||
const profile = routers.getRouter(name);
|
||||
const profile = proxies.getProxy(name);
|
||||
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
|
||||
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a delete: the files hold the deleted router's key and endpoint. Left
|
||||
* After a delete: the files hold the deleted proxy's key and endpoint. Left
|
||||
* behind, a later profile that slugifies to the same name would also inherit
|
||||
* this one's `base_url` in Codex.
|
||||
* this one's `base_url` in Codex. The pre-rename `router-settings/` copy goes too.
|
||||
*/
|
||||
function removeProfileFiles(name) {
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name)]) {
|
||||
const legacy = path.join(getDataDir(), "router-settings", `${name}.json`);
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy]) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
@@ -220,13 +224,13 @@ function removeProfileFiles(name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the old per-workspace router out, once: the eight env keys AgentHub
|
||||
* Take the old per-workspace proxy out, once: the eight env keys AgentHub
|
||||
* wrote into a claude workspace's `.claude/settings.local.json` (they would
|
||||
* otherwise route every session there, native ones included) and the
|
||||
* `lanes.router_profile` column. A lane whose file cannot be cleaned (not
|
||||
* JSON, say) keeps its column and is tried again at the next start.
|
||||
*/
|
||||
function clearLaneRouters() {
|
||||
function clearLaneProxies() {
|
||||
const { db } = require("../db");
|
||||
const { patchSettings } = require("./cc-mutate");
|
||||
const keys = Object.keys(envForProfile({ models: {} })).map((k) => `env.${k}`);
|
||||
@@ -240,17 +244,17 @@ function clearLaneRouters() {
|
||||
}
|
||||
db.prepare("UPDATE lanes SET router_profile = NULL WHERE id = ?").run(lane.id);
|
||||
} catch (e) {
|
||||
console.warn(`[router] could not clear workspace ${lane.id}'s old router: ${e.message}`);
|
||||
console.warn(`[proxy] could not clear workspace ${lane.id}'s old proxy: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveRouter,
|
||||
resumeRouter,
|
||||
routerForSpawn,
|
||||
resolveProxy,
|
||||
resumeProxy,
|
||||
proxyForSpawn,
|
||||
refreshProfileFiles,
|
||||
removeProfileFiles,
|
||||
clearLaneRouters,
|
||||
clearLaneProxies,
|
||||
CODEX_KEY_ENV,
|
||||
};
|
||||
+17
-17
@@ -31,7 +31,7 @@
|
||||
*/
|
||||
|
||||
const tmux = require("./tmux");
|
||||
const { resolveRouter, routerForSpawn } = require("./router-apply");
|
||||
const { resolveProxy, proxyForSpawn } = require("./proxy-apply");
|
||||
const { desktopEnv } = require("./desktop-env");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const aiTools = require("./ai-tools");
|
||||
@@ -131,7 +131,7 @@ function buildClaudeArgv({
|
||||
initialPrompt,
|
||||
addDirs = [],
|
||||
systemPromptFile = null,
|
||||
routerArgv = [],
|
||||
proxyArgv = [],
|
||||
}) {
|
||||
const argv = ["claude"];
|
||||
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
||||
@@ -139,8 +139,8 @@ function buildClaudeArgv({
|
||||
// always ends it — after the prompt it would eat the prompt as a directory.
|
||||
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
||||
argv.push("--permission-mode", permissionMode || "auto");
|
||||
// The session's AI Router: `--settings=<file>` (router-apply.js).
|
||||
argv.push(...routerArgv);
|
||||
// The session's AI Proxy: `--settings=<file>` (proxy-apply.js).
|
||||
argv.push(...proxyArgv);
|
||||
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
|
||||
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
|
||||
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
|
||||
@@ -182,7 +182,7 @@ function buildCodexArgv({
|
||||
resumeLast,
|
||||
bypass,
|
||||
initialPrompt,
|
||||
routerArgv = [],
|
||||
proxyArgv = [],
|
||||
addDirs = [],
|
||||
systemPrompt = null,
|
||||
systemPromptFile = null,
|
||||
@@ -191,7 +191,7 @@ function buildCodexArgv({
|
||||
// Before the subcommand and before the prompt: `-p` layers
|
||||
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
|
||||
// lands after the positional prompt is not an option any more.
|
||||
argv.push(...routerArgv);
|
||||
argv.push(...proxyArgv);
|
||||
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
||||
else if (resumeLast) argv.push("resume", "--last");
|
||||
if (model) argv.push("-m", model);
|
||||
@@ -266,7 +266,7 @@ function buildArgv(args, entry) {
|
||||
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
|
||||
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
|
||||
* @param {string} [args.initialPrompt]
|
||||
* @param {string} [args.router] AI Router profile THIS session runs on — a
|
||||
* @param {string} [args.proxy] AI Proxy profile THIS session runs on — a
|
||||
* name only, its key and endpoint come from the store; null/"native" = the
|
||||
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
|
||||
*/
|
||||
@@ -288,7 +288,7 @@ function spawnRun(args) {
|
||||
resumeLast,
|
||||
bypass,
|
||||
initialPrompt,
|
||||
router,
|
||||
proxy,
|
||||
} = args || {};
|
||||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
@@ -345,7 +345,7 @@ function spawnRun(args) {
|
||||
if (worktree === true && entry.launcher !== "claude") {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||
}
|
||||
const routerProfile = resolveRouter(tool, router); // ENOTFOUND, EUNSUPPORTED
|
||||
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
|
||||
if (forkSession === true) {
|
||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||
if (entry.launcher !== "claude") {
|
||||
@@ -382,7 +382,7 @@ function spawnRun(args) {
|
||||
laneId,
|
||||
tool
|
||||
);
|
||||
const routerSpawn = routerForSpawn(tool, routerProfile);
|
||||
const proxySpawn = proxyForSpawn(tool, proxyProfile);
|
||||
// A new Claude session gets its id from us, so the run knows the session it
|
||||
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
|
||||
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
|
||||
@@ -406,7 +406,7 @@ function spawnRun(args) {
|
||||
resumeLast,
|
||||
bypass: bypass === true,
|
||||
initialPrompt,
|
||||
routerArgv: routerSpawn.argv,
|
||||
proxyArgv: proxySpawn.argv,
|
||||
},
|
||||
entry
|
||||
);
|
||||
@@ -432,7 +432,7 @@ function spawnRun(args) {
|
||||
effort: effort || null,
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
worktree: worktree === true || inWorktree === true,
|
||||
router: routerProfile ? routerProfile.name : null,
|
||||
proxy: proxyProfile ? proxyProfile.name : null,
|
||||
prompt: initialPrompt || "",
|
||||
status: "running",
|
||||
startedAt,
|
||||
@@ -446,9 +446,9 @@ function spawnRun(args) {
|
||||
// from inside it then resolves the lane exactly, instead of matching on cwd —
|
||||
// which is ambiguous now that one directory can back both a `claude` lane and
|
||||
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
|
||||
// A codex session on an AI Router also needs the key its layer's `env_key`
|
||||
// A codex session on an AI Proxy also needs the key its layer's `env_key`
|
||||
// names. A claude one needs nothing here — its `--settings` file carries the
|
||||
// router — and a session on the vendor's own API needs nothing either.
|
||||
// proxy — and a session on the vendor's own API needs nothing either.
|
||||
// The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the
|
||||
// server normally runs as a systemd user service with neither, so a pane it
|
||||
// starts has no clipboard at all — pasting an image into `claude` there
|
||||
@@ -466,14 +466,14 @@ function spawnRun(args) {
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
...desktopEnv(),
|
||||
// A codex pane must read the same home the dashboard reads — its rollout
|
||||
// journals and the router layer `-p <name>` loads live there — and the
|
||||
// journals and the proxy layer `-p <name>` loads live there — and the
|
||||
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
||||
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
|
||||
...routerSpawn.env,
|
||||
...proxySpawn.env,
|
||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||
};
|
||||
|
||||
@@ -571,7 +571,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
worktree: false,
|
||||
router: null,
|
||||
proxy: null,
|
||||
prompt: "",
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
|
||||
+12
-1
@@ -77,7 +77,18 @@ function envFlags(env) {
|
||||
*/
|
||||
function newSession({ name, cwd, argv, env }) {
|
||||
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), "--", ...argv]);
|
||||
enableMouse(name);
|
||||
try {
|
||||
enableMouse(name);
|
||||
} catch (err) {
|
||||
// The pane's command exited at once (a CLI that refuses the terminal tmux
|
||||
// gives it, say) and tmux closed the session with it: say that, not the
|
||||
// "Command failed: tmux set-option …" that hid the real problem.
|
||||
if (hasSession(name)) throw err;
|
||||
throw Object.assign(
|
||||
new Error(`the session's command exited as soon as it started: ${argv[0]}`),
|
||||
{ code: "EPANEEXITED" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Without this the pane has no scrollbar/drag scroll at all — wheel events
|
||||
|
||||
@@ -39,7 +39,7 @@ const schemas = {
|
||||
},
|
||||
resume: { type: "boolean", description: "Offers Resume last session" },
|
||||
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
|
||||
routerCapable: { type: "boolean", description: "Can use an AI Router profile" },
|
||||
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
|
||||
install: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
|
||||
@@ -585,7 +585,7 @@ const paths = {
|
||||
},
|
||||
403: {
|
||||
description:
|
||||
"ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).",
|
||||
"ESECRET — `.claude/settings.local.json` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
|
||||
@@ -329,17 +329,6 @@ const schemas = {
|
||||
description:
|
||||
"True when the real environment, or the checkout's own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.",
|
||||
},
|
||||
routers: {
|
||||
type: "object",
|
||||
properties: {
|
||||
missing: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"`<lane id> (<title>): <profile>` for every lane naming an AI Router profile the new home's store (`<CLAUDE_HOME>/agenthub-routers/`) does not have — such a lane starts on its base configuration.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1233,7 +1222,7 @@ const paths = {
|
||||
tags: ["Settings"],
|
||||
summary: "Get the active Codex home directory",
|
||||
description:
|
||||
"Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Router layers into, and hands every codex lane pane through tmux `-e`.",
|
||||
"Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Proxy layers into at each spawn, and hands every codex lane pane through tmux `-e`.",
|
||||
operationId: "getCodexHome",
|
||||
responses: {
|
||||
200: {
|
||||
@@ -1258,7 +1247,7 @@ const paths = {
|
||||
tags: ["Settings"],
|
||||
summary: "Update the Codex home directory",
|
||||
description:
|
||||
"Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). On success it re-writes the AI Router layer (`<name>.config.toml`) of every codex lane on a router into the new home, reported as `routers`, because `codex -p <name>` loads it from there. A codex pane already running keeps its old home until restarted.",
|
||||
"Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). Nothing is re-written here: a session's AI Proxy layer (`<name>.config.toml`) is written into the current home at its spawn. A codex pane already running keeps its old home until restarted.",
|
||||
operationId: "updateCodexHome",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -1280,26 +1269,17 @@ const paths = {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["ok", "codex_home", "env_owned", "routers"],
|
||||
required: ["ok", "codex_home", "env_owned"],
|
||||
properties: {
|
||||
ok: { type: "boolean", enum: [true] },
|
||||
codex_home: { type: "string" },
|
||||
env_owned: { type: "boolean" },
|
||||
routers: {
|
||||
type: "object",
|
||||
required: ["updated", "failed"],
|
||||
properties: {
|
||||
updated: { type: "integer" },
|
||||
failed: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
example: {
|
||||
ok: true,
|
||||
codex_home: "/Users/son/.codex-work",
|
||||
env_owned: false,
|
||||
routers: { updated: 1, failed: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @file ai-routers.js (routes)
|
||||
* @description CRUD for AI Router profiles — the endpoints behind the
|
||||
* **AI Router** tab of Settings. CHOOSING a profile is not here: a new session
|
||||
* names one (`router` on `POST /api/lanes/:id/start` and `POST /api/run`).
|
||||
* @file ai-proxies.js (routes)
|
||||
* @description CRUD for AI Proxy profiles — the endpoints behind the
|
||||
* **AI Proxy** page of Settings, mounted at `/api/ai-proxies` and, for a
|
||||
* client older than the rename, `/api/ai-routers`. CHOOSING a profile is not
|
||||
* here: a new session names one (`proxy` on `POST /api/lanes/:id/start` and
|
||||
* `POST /api/run`).
|
||||
*
|
||||
* An edit or a delete here does reach the sessions using it: each reads the
|
||||
* profile through a file written at its spawn (`server/lib/router-apply.js`),
|
||||
* profile through a file written at its spawn (`server/lib/proxy-apply.js`),
|
||||
* which an edit rewrites and a delete removes.
|
||||
*
|
||||
* No response on this router carries key material. `GET /` returns the store's
|
||||
@@ -18,8 +20,8 @@
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const { refreshProfileFiles, removeProfileFiles } = require("../lib/router-apply");
|
||||
const proxies = require("../lib/ai-proxies");
|
||||
const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-apply");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
@@ -40,48 +42,50 @@ function fail(res, e) {
|
||||
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
// GET /api/ai-routers — every profile, built-in `native` first. Never a key.
|
||||
// GET /api/ai-proxies — every profile, built-in `native` first. Never a key.
|
||||
router.get("/", (_req, res) => {
|
||||
try {
|
||||
res.json(routers.listRouters());
|
||||
res.json(proxies.listProxies());
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-routers — body: { label, baseUrl, apiKey?, model, models? }
|
||||
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
res.status(201).json(routers.createRouter(req.body || {}));
|
||||
res.status(201).json(proxies.createProxy(req.body || {}));
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-routers/test — does this endpoint + key + model actually answer?
|
||||
// POST /api/ai-proxies/test — does this endpoint + key + model actually answer,
|
||||
// for Claude Code (`/v1/messages`) and for Codex (`/v1/responses`)?
|
||||
// Body: `{ baseUrl, model, apiKey?, name? }`. Registered before `/:name` for
|
||||
// clarity; no collision today, since `/:name` takes no POST.
|
||||
//
|
||||
// A reachable router is a 200 here whatever it replies — `{ ok:false, status }`
|
||||
// carries the router's own words, which is the whole point. Only bad input is
|
||||
// a 4xx. The response never echoes the key, including on the path where the
|
||||
// stored one was used (see `testRouter` on why that path is narrow).
|
||||
// A reachable proxy is a 200 here whatever it replies — `{ claude, codex }`,
|
||||
// each `{ ok, status, message }` with the proxy's own words, which is the whole
|
||||
// point. Only bad input is a 4xx. The response never echoes the key, including
|
||||
// on the path where the stored one was used (see `testProxy` on why that path
|
||||
// is narrow).
|
||||
router.post("/test", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
res.json(await routers.testRouter(req.body || {}));
|
||||
res.json(await proxies.testProxy(req.body || {}));
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/ai-routers/:name — same fields, all optional. An omitted `apiKey`
|
||||
// PATCH /api/ai-proxies/:name — same fields, all optional. An omitted `apiKey`
|
||||
// keeps the stored one; the edit form cannot show a key, so it cannot echo one.
|
||||
//
|
||||
// A session reads the profile through a file written at its spawn, so the
|
||||
// edit rewrites those files too — a rotated key reaches live Claude sessions.
|
||||
router.patch("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const saved = routers.updateRouter(req.params.name, req.body || {});
|
||||
const saved = proxies.updateProxy(req.params.name, req.body || {});
|
||||
refreshProfileFiles(saved.name);
|
||||
res.json(saved);
|
||||
} catch (e) {
|
||||
@@ -89,10 +93,10 @@ router.patch("/:name", sameOriginGuard, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/ai-routers/:name — and the session files that copy its key.
|
||||
// DELETE /api/ai-proxies/:name — and the session files that copy its key.
|
||||
router.delete("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const removed = routers.removeRouter(req.params.name);
|
||||
const removed = proxies.removeProxy(req.params.name);
|
||||
removeProfileFiles(req.params.name);
|
||||
res.json(removed);
|
||||
} catch (e) {
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file HTTP surface for AgentHub's own git credentials (git-credentials.js):
|
||||
* list, save/replace, delete. Like the AI Router routes, nothing here ever
|
||||
* list, save/replace, delete. Like the AI Proxy routes, nothing here ever
|
||||
* returns a password — `GET` reports `hasToken` — and every write is behind
|
||||
* the same-origin guard. A lane's fetch/pull/push also saves one, through
|
||||
* `POST /api/lanes/:id/git/:action` with `credentials.remember`.
|
||||
|
||||
+10
-9
@@ -28,7 +28,7 @@ const laneFeatures = require("../lib/lane-features");
|
||||
const proofLib = require("../lib/proof");
|
||||
const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/pty-run");
|
||||
const { resumeRouter } = require("../lib/router-apply");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { pickPath } = require("../lib/native-dialog");
|
||||
@@ -927,7 +927,7 @@ router.get("/:id/git/file", async (req, res) => {
|
||||
);
|
||||
res.json({ available: true, ...file });
|
||||
} catch (err) {
|
||||
// A secret file (the router's API key) is refused at every rev.
|
||||
// A secret file (where a proxy's API key once lived) is refused at every rev.
|
||||
if (err.code === "ESECRET") {
|
||||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
@@ -2002,12 +2002,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// A NEW session in a worktree of its own (`claude --worktree`).
|
||||
worktree: body.worktree === true && !body.resumeSessionId,
|
||||
tool: sessionTool || current.tool || "claude",
|
||||
// The SESSION's AI Router, by name; a resume that names none goes
|
||||
// back to the one its session last ran on.
|
||||
router:
|
||||
body.router !== undefined
|
||||
? body.router
|
||||
: resumeRouter(body.resumeSessionId || (body.resume ? current.session_id : null)),
|
||||
// The SESSION's AI Proxy, by name; a resume that names none goes
|
||||
// back to the one its session last ran on. `router` is the
|
||||
// pre-rename field, still taken from an older client.
|
||||
proxy:
|
||||
(body.proxy ?? body.router) !== undefined
|
||||
? (body.proxy ?? body.router)
|
||||
: resumeProxy(body.resumeSessionId || (body.resume ? current.session_id : null)),
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
effort: body.effort,
|
||||
@@ -2020,7 +2021,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
startedRunId = handle.id;
|
||||
// An extra pane's session is not the lane's run: `run_id` stays the
|
||||
// pane-0 session every other path (reset, router restart) names.
|
||||
// pane-0 session every other path (reset, stop) names.
|
||||
if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ const tmux = require("../lib/tmux");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { resumeRouter } = require("../lib/router-apply");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -341,12 +341,13 @@ router.post("/", (req, res) => {
|
||||
laneId,
|
||||
cwd,
|
||||
tool: lane?.tool || "claude",
|
||||
// The session's AI Router, by name (key and endpoint from the store); a
|
||||
// The session's AI Proxy, by name (key and endpoint from the store); a
|
||||
// resume that names none goes back to the one its session last ran on.
|
||||
router:
|
||||
body.router !== undefined
|
||||
? body.router
|
||||
: resumeRouter(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
|
||||
// `router` is the pre-rename field, still taken from an older client.
|
||||
proxy:
|
||||
(body.proxy ?? body.router) !== undefined
|
||||
? (body.proxy ?? body.router)
|
||||
: resumeProxy(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
|
||||
model: typeof body.model === "string" && body.model ? body.model : null,
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
|
||||
@@ -461,7 +461,7 @@ function homeRoutes(route, key, field, get, set) {
|
||||
|
||||
// GET/PUT /api/settings/claude-home
|
||||
homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome);
|
||||
// GET/PUT /api/settings/codex-home — a session's AI Router layer is written
|
||||
// GET/PUT /api/settings/codex-home — a session's AI Proxy layer is written
|
||||
// into the current home at its spawn, so a move needs no repair here.
|
||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user