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:
2026-10-06 22:48:09 +07:00
parent dd9b23106c
commit 5fe5d51b1b
82 changed files with 1792 additions and 1412 deletions
+1 -1
View File
@@ -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 |
+330
View File
@@ -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",
});
});
});
-288
View File
@@ -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",
});
});
});
+2 -2
View File
@@ -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);
});
});
+1 -1
View File
@@ -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";
+8 -5
View File
@@ -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,
+13 -13
View File
@@ -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) {
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+3 -3
View File
@@ -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,
};
+1 -1
View File
@@ -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
+11 -11
View File
@@ -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,
};
+4 -1
View File
@@ -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++;
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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. */
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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." },
},
+3 -23
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 {};
});
+7 -6
View File
@@ -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)
+1 -1
View File
@@ -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);