feat(settings): add AI Router profiles and attach one per lane
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.
The two CLIs are configured differently, and the difference is theirs:
- a claude lane gets eight `env` keys merged into its own
`.claude/settings.local.json` (`.local` because the value holds a key);
- a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
`codex -p <name>` loads — because Codex has NO project-level config.
Its base `config.toml` is never touched.
Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).
Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.
Three things this had to get right, each found by review and verified
against the real tools rather than assumed:
- `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
supported"), so every codex lane on a router would have failed to start.
The layer writes "responses", and writes `env_key` only when the profile
actually has a key — naming an unset variable is itself a startup error.
- The key must never be TYPED into a pane. `tmux.sendCommand` used
`send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
every attached browser and bash appends to history; it now takes over the
idle pane with `respawn-pane -k -e`, passing env through argv the way
`newSession` always has. Verified on tmux 3.4: multi-element argv arrives
intact and the pane renders no trace of the value.
- Attaching COPIES a profile, so editing or deleting one in Settings has to
reach the lanes already using it. An edit re-applies it (a rotated key
would otherwise never reach the lane still holding the revoked one); a
delete resets those lanes to native and removes the Codex layer file.
Both report per-lane failures, which the tab surfaces.
`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.
DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @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" });
|
||||
});
|
||||
});
|
||||
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe("pty-run", () => {
|
||||
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
|
||||
});
|
||||
|
||||
it("spawnRun types the argv into an existing session idling at a shell prompt", () => {
|
||||
it("spawnRun runs the argv in an existing session idling at a shell prompt", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
@@ -103,24 +103,24 @@ describe("pty-run", () => {
|
||||
});
|
||||
pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
|
||||
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
|
||||
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
|
||||
assert.ok(literal, "expected a literal send-keys with the command line");
|
||||
assert.match(literal[4], /^AGENTHUB_LANE_ID='8' 'claude' .*'--resume' 'abc12345'$/);
|
||||
assert.ok(
|
||||
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
|
||||
"expected the command to be submitted"
|
||||
);
|
||||
const respawn = calls.find((c) => c[0] === "respawn-pane");
|
||||
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
|
||||
const argv = respawn.slice(respawn.indexOf("--") + 1);
|
||||
assert.equal(argv[0], "claude");
|
||||
assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]);
|
||||
});
|
||||
|
||||
it("spawnRun single-quotes an initial prompt typed into an existing shell pane", () => {
|
||||
let literal = null;
|
||||
it("spawnRun passes an initial prompt as one argv element, never a shell line", () => {
|
||||
// No shell is involved any more, so a prompt full of shell metacharacters
|
||||
// is just a string — but it must still arrive whole and unmangled.
|
||||
let respawn = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "display-message") return "zsh\n";
|
||||
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
|
||||
if (args[0] === "respawn-pane") respawn = args;
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" });
|
||||
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
|
||||
assert.equal(respawn.at(-1), "don't; rm -rf /");
|
||||
});
|
||||
|
||||
it("spawnRun puts AGENTHUB_LANE_ID in the new session's environment", () => {
|
||||
@@ -142,17 +142,22 @@ describe("pty-run", () => {
|
||||
assert.ok(at < newSessionArgv.indexOf("--"));
|
||||
});
|
||||
|
||||
it("spawnRun prefixes AGENTHUB_LANE_ID when typing into an existing shell pane", () => {
|
||||
let literal = null;
|
||||
it("spawnRun passes AGENTHUB_LANE_ID through tmux -e when reusing a shell pane", () => {
|
||||
let respawn = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "display-message") return "bash\n";
|
||||
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
|
||||
if (args[0] === "respawn-pane") respawn = args;
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
|
||||
// An assignment prefix on the same line: tmux's -e only applies at session
|
||||
// creation, and a live shell takes its env from the command line.
|
||||
assert.match(literal, /^AGENTHUB_LANE_ID='12' '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
|
||||
// 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");
|
||||
assert.equal(respawn[at + 1], "AGENTHUB_LANE_ID=12");
|
||||
assert.ok(at < respawn.indexOf("--"));
|
||||
assert.equal(respawn.at(-1), "codex");
|
||||
});
|
||||
|
||||
it("spawnRun with resumeSessionId passes --resume in argv", () => {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* @file Tests for attaching an AI Router profile to a lane — the Claude Code
|
||||
* `settings.local.json` env merge and the Codex `<name>.config.toml` layer,
|
||||
* including the guarantees that neither write destroys anything already there.
|
||||
* @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-router-apply-"));
|
||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||
process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex");
|
||||
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 lanesLib = require("../lib/lanes");
|
||||
const {
|
||||
applyRouterToLane,
|
||||
reapplyToLanes,
|
||||
detachFromLanes,
|
||||
routerEnvForSpawn,
|
||||
routerArgvForSpawn,
|
||||
} = require("../lib/router-apply");
|
||||
|
||||
const LANE_CWD = path.join(SUITE_ROOT, "project");
|
||||
const SETTINGS = path.join(LANE_CWD, ".claude", "settings.local.json");
|
||||
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(process.env.CLAUDE_HOME, "agenthub-routers"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
fs.rmSync(LANE_CWD, { recursive: true, force: true });
|
||||
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(LANE_CWD, { recursive: true });
|
||||
// The DB outlives each test; a profile name is reused, so lanes left behind
|
||||
// would be picked up by the next test's `reapplyToLanes` and inflate its count.
|
||||
for (const lane of lanesLib.listLanes()) lanesLib.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
function saveNineRouter() {
|
||||
return routers.createRouter({
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "Highest",
|
||||
});
|
||||
}
|
||||
|
||||
const claudeLane = () => ({ id: 7, cwd: LANE_CWD, tool: "claude" });
|
||||
const codexLane = () => ({ id: 8, cwd: LANE_CWD, tool: "codex" });
|
||||
|
||||
describe("router-apply — Claude lane", () => {
|
||||
it("writes the eight env keys the reference settings file swaps", () => {
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
|
||||
assert.equal(env.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
|
||||
assert.equal(env.ANTHROPIC_MODEL, "Highest");
|
||||
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Highest");
|
||||
assert.equal(env.ANTHROPIC_DEFAULT_SONNET_MODEL, "Highest");
|
||||
assert.equal(env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "Highest");
|
||||
assert.equal(env.ANTHROPIC_SMALL_FAST_MODEL, "Highest");
|
||||
assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, "Highest");
|
||||
});
|
||||
|
||||
it("keeps every unrelated key in the file it merges into", () => {
|
||||
// A lane's settings.local.json is the user's file; this feature owns eight
|
||||
// keys in it and nothing else.
|
||||
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
SETTINGS,
|
||||
JSON.stringify({
|
||||
permissions: { defaultMode: "bypassPermissions" },
|
||||
env: { MY_OWN_VAR: "keep me", ANTHROPIC_MODEL: "to be replaced" },
|
||||
})
|
||||
);
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
|
||||
assert.equal(saved.permissions.defaultMode, "bypassPermissions");
|
||||
assert.equal(saved.env.MY_OWN_VAR, "keep me");
|
||||
assert.equal(saved.env.ANTHROPIC_MODEL, "Highest");
|
||||
});
|
||||
|
||||
it("writes the file 0600, because it now holds an API key", () => {
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
assert.equal(fs.statSync(SETTINGS).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
it("blanks the connection variables for the native profile instead of dropping them", () => {
|
||||
// Absent keys would let a user-level router base URL apply to a lane that
|
||||
// explicitly asked for native.
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
|
||||
assert.equal(env.ANTHROPIC_BASE_URL, "");
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "");
|
||||
});
|
||||
|
||||
it("removes the model names for the native profile rather than pinning them", () => {
|
||||
// The opposite of the connection pair above, and for the opposite reason:
|
||||
// a project-scope model name overrides the user's own global choice, so a
|
||||
// lane on native must leave that choice alone instead of writing whatever
|
||||
// `claude-*` names this codebase happened to ship with.
|
||||
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
|
||||
fs.writeFileSync(SETTINGS, JSON.stringify({ env: { MY_OWN_VAR: "keep me" } }));
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
|
||||
assert.equal(env.ANTHROPIC_MODEL, undefined);
|
||||
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, undefined);
|
||||
assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, undefined);
|
||||
assert.equal(env.MY_OWN_VAR, "keep me"); // clearing ours touches nothing else
|
||||
});
|
||||
|
||||
it("refuses a profile that does not exist rather than writing a broken file", () => {
|
||||
assert.throws(() => applyRouterToLane(claudeLane(), "ghost"), { code: "ENOTFOUND" });
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
});
|
||||
|
||||
it("leaves a settings file that is not valid JSON alone", () => {
|
||||
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
|
||||
fs.writeFileSync(SETTINGS, "{not json");
|
||||
const { name } = saveNineRouter();
|
||||
assert.throws(() => applyRouterToLane(claudeLane(), name), { code: "EBADJSON" });
|
||||
assert.equal(fs.readFileSync(SETTINGS, "utf8"), "{not json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — the written file must survive a lane reset", () => {
|
||||
it("excludes settings.local.json from git, so `git clean -fd` cannot delete it", () => {
|
||||
// `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js).
|
||||
// Without -x that spares IGNORED files only — an untracked settings file
|
||||
// would be deleted, silently detaching the lane from its router. The same
|
||||
// entry also stops an API key from being committable.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
});
|
||||
|
||||
it("excludes it for a native lane too, not only a lane on a router", () => {
|
||||
// The blanked connection pair is load-bearing in its own right: losing it
|
||||
// to `git clean -fd` lets a USER-level router take over a lane that
|
||||
// explicitly asked for native.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
});
|
||||
|
||||
it("resolves the real git dir when .git is a file, as it is in every worktree lane", () => {
|
||||
// A linked worktree's `.git` is a pointer file; joining ".git/info" onto it
|
||||
// would be a path that cannot exist.
|
||||
const gitDir = path.join(SUITE_ROOT, "realgit", "worktrees", "lane1");
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(LANE_CWD, ".git"), `gitdir: ${gitDir}\n`);
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(gitDir, "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
});
|
||||
|
||||
it("adds the entry once, however many times a router is applied", () => {
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1);
|
||||
});
|
||||
|
||||
it("still writes the settings file when the lane is not a git repo at all", () => {
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
assert.ok(fs.existsSync(SETTINGS));
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — Codex lane", () => {
|
||||
it("writes a profile layer file, never the base config", () => {
|
||||
const base = path.join(process.env.CODEX_HOME, "config.toml");
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(base, 'approval_policy = "never"\n');
|
||||
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(codexLane(), name);
|
||||
|
||||
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
|
||||
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
|
||||
assert.match(layer, /model_provider = "9router"/);
|
||||
assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/);
|
||||
assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/);
|
||||
});
|
||||
|
||||
it("keeps the key out of the config file, naming an env var instead", () => {
|
||||
// Codex reads the key from the environment; writing it into config.toml
|
||||
// would put a secret in a file with no reason to hold one.
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(codexLane(), name);
|
||||
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
|
||||
assert.ok(!layer.includes("sk-secret-value"));
|
||||
});
|
||||
|
||||
it("writes no layer file for the native profile", () => {
|
||||
// Native means "the base config as it already is" — a layer that overrode
|
||||
// nothing would still shadow a model the user set in the base.
|
||||
applyRouterToLane(codexLane(), "native");
|
||||
assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, "native.config.toml")), false);
|
||||
});
|
||||
|
||||
it("does not touch the lane's .claude directory", () => {
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(codexLane(), name);
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
});
|
||||
|
||||
it('never writes wire_api = "chat", which codex refuses to load at all', () => {
|
||||
// Verified against the installed codex-cli 0.149.0: `wire_api = "chat" is
|
||||
// no longer supported` is a hard config error, so a layer carrying it makes
|
||||
// the lane fail to start rather than fall back to anything.
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(codexLane(), name);
|
||||
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
|
||||
assert.match(layer, /wire_api = "responses"/);
|
||||
assert.ok(!layer.includes('wire_api = "chat"'));
|
||||
});
|
||||
|
||||
it("names no env_key when the profile has no key to put in it", () => {
|
||||
// A local router needs no key — the reference 9Router provider has none.
|
||||
// Naming a variable that is never set makes codex refuse to start.
|
||||
const { name } = routers.createRouter({
|
||||
label: "Local gateway",
|
||||
baseUrl: "http://127.0.0.1:20128/v1",
|
||||
apiKey: "",
|
||||
model: "Highest",
|
||||
});
|
||||
applyRouterToLane(codexLane(), name);
|
||||
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
|
||||
assert.ok(!layer.includes("env_key"), layer);
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — an edit or a delete reaches the lanes already using it", () => {
|
||||
// Attaching COPIES the profile into the lane's own config, so a store-only
|
||||
// write is inert exactly where it matters: the key a lane authenticates with.
|
||||
|
||||
/** A real lane row in a directory of its own, already attached to `profile`. */
|
||||
function laneOn(profile, dirName) {
|
||||
const cwd = path.join(SUITE_ROOT, dirName);
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const lane = lanesLib.createLane({ title: dirName, cwd, router_profile: profile });
|
||||
applyRouterToLane(lane, profile);
|
||||
return { lane, settings: path.join(cwd, ".claude", "settings.local.json") };
|
||||
}
|
||||
|
||||
const envOf = (file) => JSON.parse(fs.readFileSync(file, "utf8")).env;
|
||||
|
||||
it("pushes a rotated key out to every claude lane on that profile", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const a = laneOn(name, "rotate-a");
|
||||
const b = laneOn(name, "rotate-b");
|
||||
assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
|
||||
|
||||
routers.updateRouter(name, { apiKey: "sk-rotated" });
|
||||
const result = reapplyToLanes(name);
|
||||
|
||||
assert.equal(result.updated, 2);
|
||||
assert.deepEqual(result.failed, []);
|
||||
assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
||||
assert.equal(envOf(b.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
||||
});
|
||||
|
||||
it("leaves lanes on a different profile alone", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const other = routers.createRouter({
|
||||
label: "OpenRouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "sk-other",
|
||||
model: "auto",
|
||||
});
|
||||
const mine = laneOn(name, "mixed-mine");
|
||||
const theirs = laneOn(other.name, "mixed-theirs");
|
||||
|
||||
routers.updateRouter(name, { apiKey: "sk-rotated" });
|
||||
reapplyToLanes(name);
|
||||
|
||||
assert.equal(envOf(mine.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
||||
assert.equal(envOf(theirs.settings).ANTHROPIC_AUTH_TOKEN, "sk-other");
|
||||
});
|
||||
|
||||
it("clears the deleted router out of the lane's settings and its DB row", () => {
|
||||
// Without this the lane keeps routing to an endpoint the user believes they
|
||||
// removed, with a key they believe they revoked.
|
||||
const { name } = saveNineRouter();
|
||||
const { lane, settings } = laneOn(name, "deleted-router");
|
||||
|
||||
routers.removeRouter(name);
|
||||
const result = detachFromLanes(name);
|
||||
|
||||
assert.equal(result.updated, 1);
|
||||
assert.equal(envOf(settings).ANTHROPIC_BASE_URL, "");
|
||||
assert.equal(envOf(settings).ANTHROPIC_AUTH_TOKEN, "");
|
||||
assert.equal(envOf(settings).ANTHROPIC_MODEL, undefined);
|
||||
assert.equal(lanesLib.getLane(lane.id).router_profile, null);
|
||||
});
|
||||
|
||||
it("deletes the codex layer file, so a later same-named profile cannot inherit it", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const layer = path.join(process.env.CODEX_HOME, `${name}.config.toml`);
|
||||
applyRouterToLane(codexLane(), name);
|
||||
assert.ok(fs.existsSync(layer));
|
||||
|
||||
routers.removeRouter(name);
|
||||
detachFromLanes(name);
|
||||
assert.equal(fs.existsSync(layer), false);
|
||||
});
|
||||
|
||||
it("reports a lane it could not update instead of failing the whole edit", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const ok = laneOn(name, "partial-ok");
|
||||
const broken = laneOn(name, "partial-broken");
|
||||
fs.writeFileSync(broken.settings, "{ not json");
|
||||
|
||||
const result = reapplyToLanes(name);
|
||||
|
||||
assert.equal(result.updated, 1);
|
||||
assert.equal(result.failed.length, 1);
|
||||
assert.match(result.failed[0], /partial-broken/);
|
||||
assert.equal(envOf(ok.settings).ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
|
||||
});
|
||||
|
||||
it("has nothing to do for native, which is stored as no router at all", () => {
|
||||
laneOn("native", "native-lane");
|
||||
assert.deepEqual(reapplyToLanes("native"), { updated: 0, failed: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — what a spawn adds", () => {
|
||||
it("gives a codex lane the profile flag and the key in its environment", () => {
|
||||
const { name } = saveNineRouter();
|
||||
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), ["-p", name]);
|
||||
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), {
|
||||
AGENTHUB_ROUTER_KEY: "sk-secret-value",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds nothing for a claude lane, whose settings file already carries it", () => {
|
||||
const { name } = saveNineRouter();
|
||||
assert.deepEqual(routerArgvForSpawn({ ...claudeLane(), router_profile: name }), []);
|
||||
assert.deepEqual(routerEnvForSpawn({ ...claudeLane(), router_profile: name }), {});
|
||||
});
|
||||
|
||||
it("adds nothing for a lane with no router, or one set to native", () => {
|
||||
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: null }), []);
|
||||
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: "native" }), []);
|
||||
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: "native" }), {});
|
||||
});
|
||||
|
||||
it("adds nothing when the profile a lane names has since been deleted", () => {
|
||||
// Deleting a profile must degrade the lane to its base config, never crash
|
||||
// the spawn that was about to start.
|
||||
const { name } = saveNineRouter();
|
||||
routers.removeRouter(name);
|
||||
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), []);
|
||||
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), {});
|
||||
});
|
||||
});
|
||||
@@ -533,6 +533,16 @@ 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).
|
||||
// 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
|
||||
// PATCH would leave the row and that file disagreeing.
|
||||
try {
|
||||
db.prepare("SELECT router_profile FROM lanes LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE lanes ADD COLUMN router_profile TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate: label a dashboard run with the lane it was started through, so the
|
||||
// Workspace page can show one lane's run history. Additive and nullable —
|
||||
|
||||
@@ -73,6 +73,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 runRouter = require("./routes/run");
|
||||
const alertsRouter = require("./routes/alerts");
|
||||
const webhooksRouter = require("./routes/webhooks");
|
||||
@@ -115,6 +116,7 @@ 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);
|
||||
app.use("/api/run", runRouter);
|
||||
app.use("/api/lanes", lanesRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @file ai-routers.js
|
||||
* @description Named AI Router 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.
|
||||
*
|
||||
* A profile is exactly what the two CLIs need to talk to a router 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
|
||||
* 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.
|
||||
*
|
||||
* `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
|
||||
* edited, removed, or shadowed by a saved profile.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
/**
|
||||
* A profile's `name` is its filename, so the charset is deliberately narrow.
|
||||
* What the user reads and edits is `label`, which accepts spaces and diacritics
|
||||
* precisely because it never touches a path. Same split as cc-profiles.js.
|
||||
*/
|
||||
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
|
||||
|
||||
const LABEL_MIN = 3;
|
||||
const LABEL_MAX = 60;
|
||||
|
||||
/** The per-tier model overrides Claude Code reads, in the order the UI shows them. */
|
||||
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.
|
||||
*/
|
||||
const NATIVE = Object.freeze({
|
||||
name: BUILTIN_NAME,
|
||||
label: "Native (Claude / Codex)",
|
||||
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",
|
||||
}),
|
||||
});
|
||||
|
||||
function err(code, message) {
|
||||
const e = new Error(message);
|
||||
e.code = code;
|
||||
return e;
|
||||
}
|
||||
|
||||
function routersDir() {
|
||||
return path.join(getClaudeHome(), "agenthub-routers");
|
||||
}
|
||||
|
||||
function routerFile(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`);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Write JSON at 0600 through a temp file, so a crash never truncates the original. */
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.agenthub-tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
/** Reduce any text to the narrow NAME_RE charset, for use as a filename. */
|
||||
function slugify(text) {
|
||||
const base = String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "") // drop the combining accents NFD split off
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
return NAME_RE.test(base) ? base : "router";
|
||||
}
|
||||
|
||||
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
|
||||
function freeName(base) {
|
||||
let name = base;
|
||||
for (
|
||||
let n = 2;
|
||||
name === BUILTIN_NAME || fs.existsSync(path.join(routersDir(), `${name}.json`));
|
||||
) {
|
||||
name = `${base}-${n}`;
|
||||
n += 1;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function normalizeLabel(label) {
|
||||
const clean = typeof label === "string" ? label.trim() : "";
|
||||
if (clean.length < LABEL_MIN || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) {
|
||||
throw err(
|
||||
"EBADLABEL",
|
||||
`label must be ${LABEL_MIN}-${LABEL_MAX} characters and no control characters`
|
||||
);
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
|
||||
if (!raw) throw err("EBADURL", "baseUrl is required");
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw err("EBADURL", `not a valid URL: ${raw}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw err("EBADURL", "baseUrl must be http:// or https://");
|
||||
}
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** A model name is free text — a router 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)) {
|
||||
throw err("EBADMODEL", "model must be 1-200 characters and no control characters");
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill every slot from `model`, keeping any the caller set explicitly. This
|
||||
* is what makes "one symbolic name for every tier" — the 9Router case — a
|
||||
* single field to type instead of six.
|
||||
*/
|
||||
function normalizeModels(model, models) {
|
||||
const given = models && typeof models === "object" ? models : {};
|
||||
const out = {};
|
||||
for (const slot of MODEL_SLOTS) {
|
||||
out[slot] = given[slot] === undefined ? model : normalizeModel(given[slot]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function assertNotBuiltin(name) {
|
||||
if (name === BUILTIN_NAME) {
|
||||
throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** The browser-safe view: everything except the key, plus whether one is set. */
|
||||
function publicView(profile) {
|
||||
const { apiKey, ...rest } = profile;
|
||||
return { ...rest, hasKey: Boolean(apiKey) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every saved profile, with the built-in native one first.
|
||||
* @returns `{ items }` — key material is never included.
|
||||
*/
|
||||
function listRouters() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
.readdirSync(routersDir())
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5));
|
||||
} catch {
|
||||
/* nothing saved yet */
|
||||
}
|
||||
|
||||
const items = [publicView(NATIVE)];
|
||||
for (const name of names.sort()) {
|
||||
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
|
||||
let stored;
|
||||
try {
|
||||
stored = readRouter(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
}
|
||||
if (!stored) continue;
|
||||
items.push(publicView({ ...stored, name, builtin: false }));
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile INCLUDING its key — for the server's own writers only. No route
|
||||
* may return this shape; `listRouters` is what the browser sees.
|
||||
*/
|
||||
function getRouter(name) {
|
||||
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
|
||||
const stored = readRouter(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
return { ...stored, name, builtin: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
|
||||
* models?:Record<string,string>}} input
|
||||
* @returns the browser-safe view of what was saved
|
||||
*/
|
||||
function createRouter(input = {}) {
|
||||
const label = normalizeLabel(input.label);
|
||||
const slug = slugify(label);
|
||||
assertNotBuiltin(slug);
|
||||
|
||||
const model = normalizeModel(input.model);
|
||||
const profile = {
|
||||
label,
|
||||
baseUrl: normalizeBaseUrl(input.baseUrl),
|
||||
apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "",
|
||||
model,
|
||||
models: normalizeModels(model, input.models),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const name = freeName(slug);
|
||||
writeJson(routerFile(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.
|
||||
*/
|
||||
function updateRouter(name, changes = {}) {
|
||||
assertNotBuiltin(name);
|
||||
const stored = readRouter(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
|
||||
const model = changes.model === undefined ? stored.model : normalizeModel(changes.model);
|
||||
const next = {
|
||||
...stored,
|
||||
label: changes.label === undefined ? stored.label : normalizeLabel(changes.label),
|
||||
baseUrl: changes.baseUrl === undefined ? stored.baseUrl : normalizeBaseUrl(changes.baseUrl),
|
||||
apiKey:
|
||||
typeof changes.apiKey === "string" && changes.apiKey.trim()
|
||||
? changes.apiKey.trim()
|
||||
: stored.apiKey,
|
||||
model,
|
||||
models:
|
||||
changes.model === undefined && changes.models === undefined
|
||||
? stored.models
|
||||
: normalizeModels(model, changes.models ?? stored.models),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeJson(routerFile(name), next);
|
||||
return publicView({ ...next, name, builtin: false });
|
||||
}
|
||||
|
||||
function removeRouter(name) {
|
||||
assertNotBuiltin(name);
|
||||
const file = routerFile(name);
|
||||
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
fs.rmSync(file);
|
||||
return { removed: name };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRouters,
|
||||
getRouter,
|
||||
createRouter,
|
||||
updateRouter,
|
||||
removeRouter,
|
||||
MODEL_SLOTS,
|
||||
BUILTIN_NAME,
|
||||
NATIVE,
|
||||
};
|
||||
+42
-1
@@ -148,6 +148,7 @@ function createLane({
|
||||
base_branch = null,
|
||||
slug = null,
|
||||
tool = "claude",
|
||||
router_profile = null,
|
||||
} = {}) {
|
||||
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
|
||||
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
||||
@@ -157,7 +158,7 @@ function createLane({
|
||||
validateTool(tool);
|
||||
const info = db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run(
|
||||
title,
|
||||
@@ -169,6 +170,7 @@ function createLane({
|
||||
base_branch,
|
||||
slug,
|
||||
tool,
|
||||
router_profile || null,
|
||||
nowIso()
|
||||
);
|
||||
return getLane(info.lastInsertRowid);
|
||||
@@ -205,6 +207,43 @@ function updateLane(id, patch = {}) {
|
||||
return getLane(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record which AI Router profile a lane runs against. Deliberately its own
|
||||
* function rather than a `PATCHABLE` field: the router is also written into the
|
||||
* lane's config on disk (`server/lib/router-apply.js`), so the column may only
|
||||
* move together with that write — a bare PATCH would leave the row claiming a
|
||||
* router the lane's settings file has never heard of.
|
||||
*
|
||||
* @param {number} id - The lane id.
|
||||
* @param {string|null} profile - A profile name, or null/"native" for the
|
||||
* vendor's own API.
|
||||
*/
|
||||
function setRouterProfile(id, profile) {
|
||||
const value = !profile || profile === "native" ? null : String(profile);
|
||||
db.prepare("UPDATE lanes SET router_profile = ?, updated_at = ? WHERE id = ?").run(
|
||||
value,
|
||||
nowIso(),
|
||||
id
|
||||
);
|
||||
return getLane(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every lane attached to one AI Router profile. Editing or deleting a profile
|
||||
* has to reach the lanes that copied it (`server/lib/router-apply.js`), and this
|
||||
* is how those lanes are found.
|
||||
*
|
||||
* @param {string} profile - A profile name. `native` matches nothing: it is
|
||||
* stored as NULL, and a lane on the vendor's own API has nothing to update.
|
||||
*/
|
||||
function lanesByRouter(profile) {
|
||||
if (!profile || profile === "native") return [];
|
||||
return db
|
||||
.prepare("SELECT * FROM lanes WHERE router_profile = ? ORDER BY id ASC")
|
||||
.all(String(profile))
|
||||
.map(hydrate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach:
|
||||
* `base_branch`, resolved after `git worktree add` succeeds, and the runtime
|
||||
@@ -752,6 +791,8 @@ module.exports = {
|
||||
updateLane,
|
||||
deleteLane,
|
||||
setStage,
|
||||
setRouterProfile,
|
||||
lanesByRouter,
|
||||
recordDetection,
|
||||
clearLane,
|
||||
recoverInterruptedProvisioning,
|
||||
|
||||
+18
-2
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
|
||||
const tmux = require("./tmux");
|
||||
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
||||
|
||||
let dashboardRuns = null;
|
||||
try {
|
||||
@@ -98,8 +99,13 @@ function buildCodexArgv({
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
}) {
|
||||
const argv = ["codex"];
|
||||
// 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(...routerArgvForSpawn({ tool: "codex", router_profile: routerProfile }));
|
||||
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
||||
else if (resumeLast) argv.push("resume", "--last");
|
||||
if (model) argv.push("-m", model);
|
||||
@@ -127,6 +133,8 @@ function buildArgv(args) {
|
||||
* @param {string} [args.resumeSessionId]
|
||||
* @param {boolean} [args.resumeLast]
|
||||
* @param {string} [args.initialPrompt]
|
||||
* @param {string} [args.routerProfile] AI Router profile the LANE runs against
|
||||
* (`lanes.router_profile`), never a request field.
|
||||
*/
|
||||
function spawnRun(args) {
|
||||
const {
|
||||
@@ -141,6 +149,7 @@ function spawnRun(args) {
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
} = args || {};
|
||||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
@@ -208,6 +217,7 @@ function spawnRun(args) {
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
});
|
||||
|
||||
const record = () => {
|
||||
@@ -241,7 +251,13 @@ 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).
|
||||
const env = { AGENTHUB_LANE_ID: String(laneId) };
|
||||
// A codex lane on an AI Router also needs the key its layer's `env_key`
|
||||
// names. A claude lane needs nothing here — its settings file already carries
|
||||
// the router — and a lane on the vendor's own API needs nothing either.
|
||||
const env = {
|
||||
AGENTHUB_LANE_ID: String(laneId),
|
||||
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
tmux.newSession({ name: id, cwd, argv, env });
|
||||
@@ -253,7 +269,7 @@ function spawnRun(args) {
|
||||
// `--resume` and an initial prompt would never be typed, while the API
|
||||
// still answered 200. Run the argv in the pane the user already sees
|
||||
// instead of erroring or opening a second session.
|
||||
tmux.sendCommand(id, argv, env);
|
||||
tmux.sendCommand(id, argv, env, cwd);
|
||||
record();
|
||||
}
|
||||
// Pane is running something (a live `claude`, an editor, a build): adopt
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* @file router-apply.js
|
||||
* @description Attaches an AI Router profile (see `ai-routers.js`) to one lane.
|
||||
*
|
||||
* The two CLIs are configured in different places, and the difference is
|
||||
* theirs, not a choice made here:
|
||||
*
|
||||
* - **Claude Code** reads an `env` block out of the settings layers, so a
|
||||
* router is eight env keys merged into the lane's own
|
||||
* `.claude/settings.local.json`. `settings.local.json` and not
|
||||
* `settings.json` because the value written includes an API key, and the
|
||||
* `.local` file is the one Claude Code's own convention keeps out of git.
|
||||
*
|
||||
* - **Codex** has no project-level config at all — `codex doctor` reports a
|
||||
* single loaded file, `~/.codex/config.toml`. What it does have is
|
||||
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of that
|
||||
* base. So a profile becomes a layer file, the base is never touched, and
|
||||
* the lane's spawn passes the flag. Codex takes the key from the environment
|
||||
* variable named by `env_key`, so the key stays out of the file entirely and
|
||||
* reaches the pane through tmux's own `-e`, never as typed text.
|
||||
*
|
||||
* Target directories come from the lane row, never from a request body — the
|
||||
* same narrowing every other lane write path in this codebase uses.
|
||||
*
|
||||
* Attaching COPIES a profile, so the store is not the single source of truth
|
||||
* once a lane exists. `reapplyToLanes`/`detachFromLanes` are what keep an edit
|
||||
* or a delete in Settings from being silently inert on the lanes using it.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const routers = require("./ai-routers");
|
||||
const { patchSettings } = require("./cc-mutate");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { lanesByRouter, setRouterProfile } = require("./lanes");
|
||||
|
||||
/**
|
||||
* The env var Codex is told to read the router 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";
|
||||
|
||||
/** The eight keys this feature owns in a lane's settings file, and nothing else. */
|
||||
function envForProfile(profile) {
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: profile.apiKey,
|
||||
ANTHROPIC_MODEL: profile.model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.models.haiku,
|
||||
ANTHROPIC_SMALL_FAST_MODEL: profile.models.smallFast,
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: profile.models.subagent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a profile, or resolve to null when it has since been deleted. Used on
|
||||
* the spawn path, where a missing profile must degrade the lane to its base
|
||||
* configuration rather than refuse to start it.
|
||||
*/
|
||||
function readOrNull(name) {
|
||||
if (!name || name === routers.BUILTIN_NAME) return null;
|
||||
try {
|
||||
return routers.getRouter(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's own git directory. For a normal clone that is `<cwd>/.git`; for a
|
||||
* LINKED WORKTREE — which is what every managed lane is — `.git` is a pointer
|
||||
* FILE, and joining `info/exclude` onto it names a path that cannot exist.
|
||||
*
|
||||
* @returns {string|null} absolute git dir, or null when this is not a repo
|
||||
*/
|
||||
function gitDirOf(laneDir) {
|
||||
const dotGit = path.join(laneDir, ".git");
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(dotGit);
|
||||
} catch {
|
||||
return null; // not a git repo — a perfectly normal adopted directory
|
||||
}
|
||||
if (stat.isDirectory()) return dotGit;
|
||||
const match = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
|
||||
return match ? path.resolve(laneDir, match[1].trim()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently keep `.claude/settings.local.json` out of git, for two reasons
|
||||
* that both matter:
|
||||
*
|
||||
* - `agenthub lanes reset` runs `git clean -fd` (`server/lib/worktree.js`).
|
||||
* Without `-x` that spares IGNORED files only, so an untracked settings file
|
||||
* is deleted and the lane silently loses its router.
|
||||
* - The file holds an API key. Untracked means `git add -A` can commit it.
|
||||
*
|
||||
* Best effort: a lane that is not a repo, or a git dir that cannot be written,
|
||||
* must not fail an otherwise-successful attach.
|
||||
*/
|
||||
function excludeSettingsFromGit(laneDir) {
|
||||
const line = ".claude/settings.local.json";
|
||||
try {
|
||||
const gitDir = gitDirOf(laneDir);
|
||||
if (!gitDir) return;
|
||||
const excludePath = path.join(gitDir, "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
if (existing.split("\n").includes(line)) return;
|
||||
fs.writeFileSync(
|
||||
excludePath,
|
||||
existing && !existing.endsWith("\n") ? `${existing}\n${line}\n` : `${existing}${line}\n`
|
||||
);
|
||||
} catch {
|
||||
/* best effort — see above */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the eight keys into the lane's settings, leaving every other key —
|
||||
* including every other env var — exactly as it was.
|
||||
*
|
||||
* `patchSettings` does this already and does it better than a local merge
|
||||
* would: per-key deep merge, a size ceiling, `__proto__`-style segments
|
||||
* refused, an atomic write, a timestamped backup, and a file that is not valid
|
||||
* JSON reported (`EBADJSON`) instead of overwritten. It resolves
|
||||
* `project-local` to exactly `<cwd>/.claude/settings.local.json`. The
|
||||
* caller-chosen-cwd rule it is written against is a ROUTE rule; `lane.cwd`
|
||||
* comes from the DB row, which is the shape that rule allows.
|
||||
*
|
||||
* What it does not do is set a mode, because nothing else it writes holds a
|
||||
* secret. This one does, so the file and the backup it just made of the
|
||||
* previous one are both tightened afterwards.
|
||||
*/
|
||||
function applyToClaudeLane(lane, profile) {
|
||||
// Native is not simply "no keys". The two halves go opposite ways:
|
||||
//
|
||||
// - The CONNECTION pair is BLANKED, not removed. An absent key at project
|
||||
// scope means the user-level one applies, so dropping these would let a
|
||||
// user-level router take over a lane that explicitly asked for native.
|
||||
// - The MODEL names are REMOVED. Writing them would pin every lane — including
|
||||
// one created by a user who never opened this tab — to whatever names this
|
||||
// file shipped with, overriding the user-level choice that is the actual
|
||||
// source of truth for native.
|
||||
const env = envForProfile(profile);
|
||||
const set = {};
|
||||
let unset = [];
|
||||
if (profile.builtin) {
|
||||
const connection = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||
for (const key of connection) set[`env.${key}`] = "";
|
||||
unset = Object.keys(env)
|
||||
.filter((key) => !connection.includes(key))
|
||||
.map((key) => `env.${key}`);
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(env)) set[`env.${key}`] = value;
|
||||
}
|
||||
|
||||
const result = patchSettings({ scope: "project-local", cwd: lane.cwd, set, unset });
|
||||
// Both branches, not just the router one. A native lane's blanked connection
|
||||
// pair is load-bearing too — `git clean -fd` deleting it would let a
|
||||
// user-level router quietly take over a lane that asked for native.
|
||||
excludeSettingsFromGit(lane.cwd);
|
||||
for (const file of [result.path, result.backupPath]) {
|
||||
if (!file) continue;
|
||||
try {
|
||||
fs.chmodSync(file, 0o600);
|
||||
} catch {
|
||||
// Best effort: a write that landed must not be reported as a failure
|
||||
// because the mode could not be tightened afterwards.
|
||||
}
|
||||
}
|
||||
return { tool: "claude", file: result.path, profile: profile.name };
|
||||
}
|
||||
|
||||
/** TOML string literal — router names and URLs are validated, quotes are not free. */
|
||||
function tomlString(value) {
|
||||
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on
|
||||
* top of the base config. The base file is never read or written here.
|
||||
*/
|
||||
function applyToCodexLane(lane, profile) {
|
||||
const file = path.join(getCodexHome(), `${profile.name}.config.toml`);
|
||||
const body =
|
||||
`# Written by AgentHub — AI Router 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
|
||||
// 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` : "") +
|
||||
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is
|
||||
// no longer supported`, a hard config error before the first request. The
|
||||
// user's own working 9Router provider uses "responses" too.
|
||||
`wire_api = "responses"\n`;
|
||||
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, body, { mode: 0o600 });
|
||||
return { tool: "codex", file, profile: profile.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach `profileName` to `lane`. Called once, when the lane is created.
|
||||
*
|
||||
* @param {{id:number, cwd:string, tool:string}} lane the lane row
|
||||
* @param {string} profileName a saved profile's name, or `"native"`
|
||||
*/
|
||||
function applyRouterToLane(lane, profileName) {
|
||||
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
||||
if (lane.tool === "codex") {
|
||||
// Native is the base config as it already stands. A layer that overrode
|
||||
// nothing would still shadow whatever model the user set in the base.
|
||||
if (profile.builtin) return { tool: "codex", file: null, profile: profile.name };
|
||||
return applyToCodexLane(lane, profile);
|
||||
}
|
||||
return applyToClaudeLane(lane, profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra argv for a lane's spawn. Codex needs `-p <name>`; a Claude lane needs
|
||||
* nothing, its settings file already carries the router.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function routerArgvForSpawn(lane) {
|
||||
if (!lane || lane.tool !== "codex") return [];
|
||||
const profile = readOrNull(lane.router_profile);
|
||||
return profile ? ["-p", profile.name] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra environment for a lane's spawn — the key Codex's `env_key` names.
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
function routerEnvForSpawn(lane) {
|
||||
if (!lane || lane.tool !== "codex") return {};
|
||||
const profile = readOrNull(lane.router_profile);
|
||||
return profile && profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the attach for every lane already on `profileName`.
|
||||
*
|
||||
* Attaching COPIES the profile — a claude lane's key and base URL live in its
|
||||
* own settings file from then on. So an edit that stopped here would be inert
|
||||
* where it matters most: rotating a leaked key in Settings would leave every
|
||||
* claude lane authenticating with the revoked one, forever, with no in-product
|
||||
* way to notice or fix it.
|
||||
*
|
||||
* Per-lane best effort: a lane whose directory is gone, or whose settings file
|
||||
* a human has since broken, must not fail the edit for every other lane.
|
||||
*
|
||||
* @returns {{updated:number, failed:string[]}}
|
||||
*/
|
||||
function reapplyToLanes(profileName) {
|
||||
const failed = [];
|
||||
let updated = 0;
|
||||
for (const lane of lanesByRouter(profileName)) {
|
||||
try {
|
||||
applyRouterToLane(lane, profileName);
|
||||
updated += 1;
|
||||
} catch (e) {
|
||||
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach `profileName` from every lane using it, and delete the Codex layer
|
||||
* file it wrote.
|
||||
*
|
||||
* Deleting a profile has to reach the lanes for the same reason an edit does,
|
||||
* and more urgently: a claude lane keeps the deleted router's key and base URL
|
||||
* in its settings file and would go on routing to a dead endpoint with a key
|
||||
* the user believes they have removed. Each lane is reset to native — which now
|
||||
* UNSETS those keys — and its `router_profile` cleared.
|
||||
*
|
||||
* The layer file matters too: left behind, a later profile that slugifies to
|
||||
* the same name silently inherits this one's `base_url`.
|
||||
*/
|
||||
function detachFromLanes(profileName) {
|
||||
const failed = [];
|
||||
let updated = 0;
|
||||
for (const lane of lanesByRouter(profileName)) {
|
||||
try {
|
||||
applyRouterToLane(lane, routers.BUILTIN_NAME);
|
||||
setRouterProfile(lane.id, null);
|
||||
updated += 1;
|
||||
} catch (e) {
|
||||
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.rmSync(path.join(getCodexHome(), `${profileName}.config.toml`), { force: true });
|
||||
} catch {
|
||||
/* best effort — an orphan layer is a leak, not a failure to report */
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyRouterToLane,
|
||||
reapplyToLanes,
|
||||
detachFromLanes,
|
||||
routerArgvForSpawn,
|
||||
routerEnvForSpawn,
|
||||
CODEX_KEY_ENV,
|
||||
};
|
||||
+26
-22
@@ -6,10 +6,11 @@
|
||||
* `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes shell`). Never builds a
|
||||
* shell string — every call is `execFileSync("tmux", [...argv])` with an
|
||||
* explicit argument array (matches this repo's rule for git in worktree.js).
|
||||
* The one place a command line is composed is `sendCommand`, which types into
|
||||
* an existing pane's shell: there the shell IS the consumer, so every argument
|
||||
* is POSIX single-quoted first and sent with `send-keys -l` (literal).
|
||||
* `sendText` is the same primitive for the opposite case — a pane running an
|
||||
* No call composes a shell command line at all, `sendCommand` included: it
|
||||
* takes over an idle pane with `respawn-pane -- <argv>` rather than typing a
|
||||
* quoted line into that pane's shell, which keeps an API key passed in `env`
|
||||
* out of the pane's rendered text, its scrollback and the user's shell history.
|
||||
* `sendText` is the one primitive that does send keystrokes — a pane running an
|
||||
* interactive chat program that reads stdin as a message, not a shell.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -88,27 +89,30 @@ function paneCommand(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/** POSIX single-quote escaping — the pane is a shell, so argv must be quoted. */
|
||||
function shellQuote(arg) {
|
||||
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type `argv` into an existing session's pane and press Enter. Only ever
|
||||
* called when the pane sits at a shell prompt (see `paneCommand`); `-l` sends
|
||||
* the string literally so no character is read as a tmux key name.
|
||||
* Run `argv` in an existing session's pane. Only ever called when the pane sits
|
||||
* at a shell prompt (see `paneCommand`), so what `-k` kills is an idle shell.
|
||||
*
|
||||
* `env` rides along as a shell assignment prefix on the SAME line — tmux's own
|
||||
* `-e` applies only at session creation, and a shell that is already running
|
||||
* takes its environment from the command line. The name is never quoted (bash
|
||||
* only recognizes an assignment when the `=` is unquoted); the value is.
|
||||
* `respawn-pane` and not `send-keys`, because `env` may carry a secret — an AI
|
||||
* Router's API key, which Codex reads from the variable its `env_key` names.
|
||||
* Typing it would put a `KEY='sk-live-…' codex …` line into the pane's rendered
|
||||
* text, which the dashboard streams to every attached browser, into the tmux
|
||||
* scrollback, and into the user's shell history. `-e` hands the value to the
|
||||
* tmux server through argv instead, exactly as `newSession` already does, and
|
||||
* the pane never renders it. Running argv directly also means no shell to quote
|
||||
* for, so the whole class of quoting bugs goes with it.
|
||||
*/
|
||||
function sendCommand(name, argv, env) {
|
||||
const prefix = Object.entries(env || {})
|
||||
.map(([key, value]) => `${key}=${shellQuote(value)} `)
|
||||
.join("");
|
||||
execImpl(["send-keys", "-t", name, "-l", prefix + argv.map(shellQuote).join(" ")]);
|
||||
execImpl(["send-keys", "-t", name, "Enter"]);
|
||||
function sendCommand(name, argv, env, cwd) {
|
||||
execImpl([
|
||||
"respawn-pane",
|
||||
"-k",
|
||||
"-t",
|
||||
name,
|
||||
...(cwd ? ["-c", cwd] : []),
|
||||
...envFlags(env),
|
||||
"--",
|
||||
...argv,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file ai-routers.js (routes)
|
||||
* @description CRUD for AI Router profiles — the endpoints behind the
|
||||
* **AI Router** tab of Settings. CHOOSING a profile for a lane is not here: it
|
||||
* happens once, when the lane is created (`POST /api/lanes/:id/router/apply`),
|
||||
* because the write target is the lane's own directory.
|
||||
*
|
||||
* An edit or a delete here does reach the lanes that already chose it, though.
|
||||
* Attaching COPIES the profile into the lane's own config, so a store-only
|
||||
* write would be inert exactly where it matters most — a rotated key would
|
||||
* never reach the lanes still authenticating with the revoked one.
|
||||
*
|
||||
* No response on this router carries key material. `GET /` returns the store's
|
||||
* public view (`hasKey: true|false`), and there is deliberately no
|
||||
* `GET /:name` — a route that returned one profile in full would be the one
|
||||
* way an API key could leave this process.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const { reapplyToLanes, detachFromLanes } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ERR_TO_STATUS = {
|
||||
EBADNAME: 400,
|
||||
EBADLABEL: 400,
|
||||
EBADURL: 400,
|
||||
EBADMODEL: 400,
|
||||
EBADJSON: 409,
|
||||
EBUILTIN: 409,
|
||||
ENOTFOUND: 404,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
const status = ERR_TO_STATUS[e.code] || 500;
|
||||
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.
|
||||
router.get("/", (_req, res) => {
|
||||
try {
|
||||
res.json(routers.listRouters());
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-routers — body: { label, baseUrl, apiKey?, model, models? }
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
res.status(201).json(routers.createRouter(req.body || {}));
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/ai-routers/:name — same fields, all optional. An omitted `apiKey`
|
||||
// keeps the stored one; the edit form cannot show a key, so it cannot echo one.
|
||||
//
|
||||
// Attaching COPIES a profile into the lane's own config, so an edit that only
|
||||
// wrote the store here would be inert: rotating a leaked key would leave every
|
||||
// claude lane on the revoked one. `reapplyToLanes` pushes the change out, and
|
||||
// reports per-lane failures instead of failing the edit.
|
||||
router.patch("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const saved = routers.updateRouter(req.params.name, req.body || {});
|
||||
res.json({ ...saved, lanes: reapplyToLanes(saved.name) });
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/ai-routers/:name — every lane using it is reset to native, in its
|
||||
// config on disk as well as in the DB. Leaving the lanes alone would keep the
|
||||
// deleted router's key and base URL live in each claude lane's settings file.
|
||||
router.delete("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const removed = routers.removeRouter(req.params.name);
|
||||
res.json({ ...removed, lanes: detachFromLanes(req.params.name) });
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -25,6 +25,7 @@ const laneFeatures = require("../lib/lane-features");
|
||||
const proofLib = require("../lib/proof");
|
||||
const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/pty-run");
|
||||
const { applyRouterToLane } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
@@ -618,6 +619,32 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/router/apply — point this lane at one AI Router profile (or back at
|
||||
* `native`). Body: `{ profile: "<name>" }`.
|
||||
*
|
||||
* The profile name is the only thing taken from the request: what gets written,
|
||||
* and where, is derived from the lane row (`server/lib/router-apply.js`). The
|
||||
* lane's `router_profile` column is set in the same call so a later spawn knows
|
||||
* which `-p` layer a codex pane needs.
|
||||
*/
|
||||
router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
const profile = (req.body || {}).profile;
|
||||
if (typeof profile !== "string" || !profile) {
|
||||
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } });
|
||||
}
|
||||
try {
|
||||
const result = applyRouterToLane(lane, profile);
|
||||
lanesLib.setRouterProfile(lane.id, profile);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
|
||||
res.status(status).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect a Node.js project at this lane's OWN directory and scaffold
|
||||
* .agenthub/profile/ if one is found — the HTTP equivalent of
|
||||
@@ -1311,6 +1338,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// asked for the other CLI would otherwise get a pane that doesn't
|
||||
// match the lane's badge, its history, or its binary probe.
|
||||
tool: current.tool || "claude",
|
||||
// Same reasoning as `tool`: the router is the lane's fact, written
|
||||
// into its config when the lane was created.
|
||||
routerProfile: current.router_profile || null,
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
effort: body.effort,
|
||||
|
||||
@@ -242,6 +242,8 @@ router.post("/", (req, res) => {
|
||||
laneId,
|
||||
cwd,
|
||||
tool: lane?.tool || "claude",
|
||||
// The lane's own router, exactly like its tool — never a body field.
|
||||
routerProfile: lane?.router_profile || null,
|
||||
model: typeof body.model === "string" && body.model ? body.model : null,
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
|
||||
Reference in New Issue
Block a user