feat: AI Router per session; Settings at the sidebar's foot; shipped tool icons
AI Router is a session's choice, not a workspace's: - an AI Router select beside the model picker in the workspace home and a pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only Native; last pick remembered in agenthub.session.router) - `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets --settings=<data dir>/router-settings/<name>.json (0600, outranks project and user settings, key never in argv), Codex -p <name> + key via tmux -e - dashboard_runs.router; a resume naming none goes back to it - profile PATCH rewrites the session files, DELETE removes them - gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace router radio, the claude-/codex-home `routers` answers; clearLaneRouters takes the old keys out of each workspace's settings.local.json at start Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked on the rail) instead of in the brand's corner. Icons: shipped SVG marks for claude, codex, openclaw and hermes (a black/white pair swapped by theme) in client/public/ai-tools/, used by AiToolIcon before the server's favicon.
This commit is contained in:
+2
-2
@@ -682,9 +682,9 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
| `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks |
|
||||
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults, then run the automatic pricing sync at once (waiting up to 3 s). Body `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) deletes and reseeds only `claude-…` rows; no body resets every row |
|
||||
| `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`). Answers `routers: {missing: [...]}` — workspaces naming an AI Router profile the new `<CLAUDE_HOME>/agenthub-routers/` store lacks (they start on native) |
|
||||
| `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`; answers `routers: {updated, failed}` after re-writing every codex workspace's AI Router layer into the new home (`reapplyCodexLayers`) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -278,65 +278,21 @@ describe("/api/lanes", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("broadcasts lane_update on router apply", async () => {
|
||||
// Without this the client's cached row keeps the OLD router_profile: the
|
||||
// picker looks right until the user switches lanes and back, at which
|
||||
// point the effect re-reads the stale row and flips the selection.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-"));
|
||||
const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" });
|
||||
const id = created.body.lane.id;
|
||||
// Start somewhere other than native, so the broadcast has to carry a
|
||||
// CHANGED value rather than the one the row already held.
|
||||
require("../lib/lanes").setRouterProfile(id, "some-router");
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
|
||||
const messages = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on("open", resolve);
|
||||
ws.on("error", reject);
|
||||
});
|
||||
ws.on("message", (msg) => {
|
||||
try {
|
||||
messages.push(JSON.parse(msg));
|
||||
} catch {
|
||||
/* parse error */
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(applied.status, 200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id);
|
||||
assert.ok(msg, "router apply should broadcast lane_update");
|
||||
// `native` is stored as null — what matters is that the pushed row is the
|
||||
// post-apply one, not the "some-router" the client still has cached.
|
||||
assert.equal(msg.data.lane.router_profile, null);
|
||||
} finally {
|
||||
// A socket left open by a failed assertion keeps the whole run alive.
|
||||
ws.close();
|
||||
}
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("router apply asks for a restart only while the lane's pane is live", async () => {
|
||||
it("starts a session on the router it names, and refuses one the store lacks", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-live-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-start-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
try {
|
||||
for (const live of [false, true]) {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && !live) throw new Error("no such session");
|
||||
return "";
|
||||
});
|
||||
const r = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.restart_required, live);
|
||||
assert.equal(r.body.run_id, live ? `agenthub-lane-${id}` : null);
|
||||
}
|
||||
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" });
|
||||
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.
|
||||
const gone = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(gone.status, 404);
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -267,6 +267,59 @@ describe("pty-run", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("spawnRun starts a session on ITS router: Claude by --settings, Codex by -p", () => {
|
||||
let newSessionArgv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
const e = new Error("gone");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
if (args[0] === "new-session") newSessionArgv = args;
|
||||
return "";
|
||||
});
|
||||
// The router 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],
|
||||
]);
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "claude");
|
||||
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",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "M",
|
||||
});
|
||||
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", router: name, initialPrompt: "hi" });
|
||||
const flag = newSessionArgv.find((a) => a.startsWith("--settings="));
|
||||
assert.equal(flag, `--settings=${path.join(TMP, "data", "router-settings", `${name}.json`)}`);
|
||||
assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value"));
|
||||
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").router, name);
|
||||
|
||||
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", router: 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"));
|
||||
|
||||
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.throws(
|
||||
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", router: "nope" }),
|
||||
(e) => e.code === "EBADROUTER"
|
||||
);
|
||||
} finally {
|
||||
for (const [k, v] of saved) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
|
||||
let newSessionArgv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* @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.
|
||||
* @file Tests for starting one session on an AI Router 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`.
|
||||
* @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 { execFileSync } = require("node:child_process");
|
||||
|
||||
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_DATA_DIR = path.join(SUITE_ROOT, "data");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
@@ -20,30 +21,30 @@ const assert = require("node:assert/strict");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { db } = require("../db");
|
||||
const {
|
||||
applyRouterToLane,
|
||||
reapplyToLanes,
|
||||
detachFromLanes,
|
||||
routerEnvForSpawn,
|
||||
routerArgvForSpawn,
|
||||
resolveRouter,
|
||||
resumeRouter,
|
||||
routerForSpawn,
|
||||
refreshProfileFiles,
|
||||
removeProfileFiles,
|
||||
clearLaneRouters,
|
||||
} = require("../lib/router-apply");
|
||||
|
||||
const LANE_CWD = path.join(SUITE_ROOT, "project");
|
||||
const SETTINGS = path.join(LANE_CWD, ".claude", "settings.local.json");
|
||||
const settingsFile = (name) =>
|
||||
path.join(process.env.DASHBOARD_DATA_DIR, "router-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(() => {
|
||||
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);
|
||||
for (const dir of [
|
||||
path.join(process.env.CLAUDE_HOME, "agenthub-routers"),
|
||||
process.env.CODEX_HOME,
|
||||
process.env.DASHBOARD_DATA_DIR,
|
||||
]) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function saveNineRouter() {
|
||||
@@ -55,388 +56,175 @@ function saveNineRouter() {
|
||||
});
|
||||
}
|
||||
|
||||
const claudeLane = () => ({ id: 7, cwd: LANE_CWD, tool: "claude" });
|
||||
const codexLane = () => ({ id: 8, cwd: LANE_CWD, tool: "codex" });
|
||||
const spawnFor = (tool, name) => routerForSpawn(tool, resolveRouter(tool, name));
|
||||
|
||||
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");
|
||||
describe("resolveRouter", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
// Real repos, asserted through `git status`: a fake `.git` dir let a test pass
|
||||
// while git itself never read the exclude file it checked.
|
||||
const REPO = path.join(SUITE_ROOT, "repo");
|
||||
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
|
||||
// would point these fixture calls at the outer repo.
|
||||
const git = (cwd, ...args) => {
|
||||
const env = { ...process.env };
|
||||
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
|
||||
delete env[name];
|
||||
}
|
||||
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
|
||||
};
|
||||
const untracked = (cwd) =>
|
||||
git(cwd, "status", "--porcelain", "--untracked-files=all", "--ignored=no")
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
// A machine-wide ignore (Claude Code adds one for settings.local.json) would
|
||||
// hide the very file these tests look for.
|
||||
beforeEach(() => {
|
||||
process.env.GIT_CONFIG_GLOBAL = "/dev/null";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
git(REPO, "init", "-q");
|
||||
git(
|
||||
REPO,
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init"
|
||||
it("refuses a profile the store does not have", () => {
|
||||
assert.throws(
|
||||
() => resolveRouter("claude", "nope"),
|
||||
(e) => e.code === "EBADROUTER"
|
||||
);
|
||||
});
|
||||
|
||||
const laneAt = (cwd) => ({ id: 7, cwd, tool: "claude" });
|
||||
|
||||
it("hides settings.local.json and its backups, so `git clean -fd` cannot delete them", () => {
|
||||
// `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.
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(laneAt(REPO), name);
|
||||
applyRouterToLane(laneAt(REPO), "native"); // a second write leaves a backup
|
||||
assert.deepEqual(untracked(REPO), []);
|
||||
});
|
||||
|
||||
it("hides them in a worktree lane, whose exclude file is the COMMON dir's", () => {
|
||||
// git reads info/exclude from the common git dir only; a copy under
|
||||
// .git/worktrees/<name>/info/ is never consulted.
|
||||
const wt = path.join(SUITE_ROOT, "wt");
|
||||
fs.rmSync(wt, { recursive: true, force: true });
|
||||
git(REPO, "worktree", "add", "-q", "-b", "lane1", wt);
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(laneAt(wt), name);
|
||||
applyRouterToLane(laneAt(wt), "native");
|
||||
assert.deepEqual(untracked(wt), []);
|
||||
});
|
||||
|
||||
it("hides them in a lane that is a subdirectory of its repo", () => {
|
||||
const sub = path.join(REPO, "packages", "app");
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(laneAt(sub), name);
|
||||
applyRouterToLane(laneAt(sub), "native");
|
||||
assert.deepEqual(untracked(REPO), []);
|
||||
});
|
||||
|
||||
it("adds the entry once, however many times a router is applied", () => {
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(laneAt(REPO), name);
|
||||
applyRouterToLane(laneAt(REPO), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(REPO, ".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 — Antigravity lane", () => {
|
||||
const agyLane = () => ({ id: 9, cwd: LANE_CWD, tool: "antigravity" });
|
||||
|
||||
it("accepts native and writes nothing", () => {
|
||||
assert.equal(applyRouterToLane(agyLane(), "native").file, null);
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
});
|
||||
|
||||
it("refuses a real router instead of writing Claude's settings file", () => {
|
||||
it("refuses a router on a tool with no endpoint to point at one", () => {
|
||||
const { name } = saveNineRouter();
|
||||
assert.throws(
|
||||
() => applyRouterToLane(agyLane(), name),
|
||||
(err) => err.code === "EUNSUPPORTED"
|
||||
() => resolveRouter("antigravity", name),
|
||||
(e) => e.code === "EUNSUPPORTED"
|
||||
);
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
assert.equal(resolveRouter("antigravity", "native"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — Codex lane", () => {
|
||||
it("writes a profile layer file, never the base config", () => {
|
||||
describe("routerForSpawn — Claude", () => {
|
||||
it("passes a 0600 settings file holding the eight env keys, never the key in argv", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const { argv, env } = spawnFor("claude", name);
|
||||
assert.deepEqual(argv, [`--settings=${settingsFile(name)}`]);
|
||||
assert.deepEqual(env, {});
|
||||
assert.ok(!argv.join(" ").includes("sk-secret-value"));
|
||||
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
|
||||
assert.equal(written.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
|
||||
assert.equal(written.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
|
||||
for (const k of [
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
]) {
|
||||
assert.equal(written[k], "Highest", k);
|
||||
}
|
||||
assert.equal(fs.statSync(settingsFile(name)).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
it("leaves a blank key out instead of blanking the user's own setting", () => {
|
||||
const { name } = routers.createRouter({
|
||||
label: "Local",
|
||||
baseUrl: "http://127.0.0.1:20128/v1",
|
||||
apiKey: "",
|
||||
model: "Highest",
|
||||
});
|
||||
spawnFor("claude", name);
|
||||
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
|
||||
assert.equal("ANTHROPIC_AUTH_TOKEN" in written, false);
|
||||
});
|
||||
|
||||
it("adds nothing for native", () => {
|
||||
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("routerForSpawn — Codex", () => {
|
||||
it("writes a profile layer, never the base config, and hands the key over in env", () => {
|
||||
const base = path.join(process.env.CODEX_HOME, "config.toml");
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(base, 'approval_policy = "never"\n');
|
||||
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(codexLane(), name);
|
||||
|
||||
const { argv, env } = spawnFor("codex", name);
|
||||
assert.deepEqual(argv, ["-p", name]);
|
||||
assert.deepEqual(env, { AGENTHUB_ROUTER_KEY: "sk-secret-value" });
|
||||
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
|
||||
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
|
||||
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"/);
|
||||
});
|
||||
|
||||
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");
|
||||
// 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("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.
|
||||
it("names no env_key when the profile has no key", () => {
|
||||
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);
|
||||
const { env } = spawnFor("codex", name);
|
||||
assert.deepEqual(env, {});
|
||||
assert.ok(!fs.readFileSync(layerFile(name), "utf8").includes("env_key"));
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
describe("an edit or a delete reaches the session files", () => {
|
||||
it("an edit rewrites the files that exist and creates none", () => {
|
||||
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");
|
||||
|
||||
spawnFor("claude", name);
|
||||
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");
|
||||
refreshProfileFiles(name);
|
||||
const env = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
||||
assert.equal(fs.existsSync(layerFile(name)), false);
|
||||
});
|
||||
|
||||
it("leaves lanes on a different profile alone", () => {
|
||||
it("a delete removes both files", () => {
|
||||
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: [] });
|
||||
spawnFor("claude", name);
|
||||
spawnFor("codex", name);
|
||||
removeProfileFiles(name);
|
||||
assert.equal(fs.existsSync(settingsFile(name)), false);
|
||||
assert.equal(fs.existsSync(layerFile(name)), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — what a spawn adds", () => {
|
||||
it("gives a codex lane the profile flag and the key in its environment", () => {
|
||||
describe("resumeRouter", () => {
|
||||
it("goes back to the router the session last ran on, while it exists", () => {
|
||||
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",
|
||||
const runs = require("../lib/dashboard-runs");
|
||||
runs.recordRun({
|
||||
id: "agenthub-lane-91",
|
||||
sessionId: "sess-router-1",
|
||||
mode: "interactive",
|
||||
router: name,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
assert.equal(resumeRouter("sess-router-1"), name);
|
||||
routers.removeRouter(name);
|
||||
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), []);
|
||||
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), {});
|
||||
assert.equal(resumeRouter("sess-router-1"), null);
|
||||
assert.equal(resumeRouter("sess-unknown"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearLaneRouters", () => {
|
||||
it("takes the old router's keys out of a workspace's settings and clears the column", () => {
|
||||
const cwd = path.join(SUITE_ROOT, "old-lane");
|
||||
const file = path.join(cwd, ".claude", "settings.local.json");
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
permissions: { allow: ["Bash(ls)"] },
|
||||
env: { ANTHROPIC_BASE_URL: "http://x", ANTHROPIC_AUTH_TOKEN: "sk", MY_VAR: "1" },
|
||||
})
|
||||
);
|
||||
const lane = lanesLib.createLane({ title: "old", cwd, router_profile: "9router" });
|
||||
clearLaneRouters();
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.deepEqual(data.env, { MY_VAR: "1" });
|
||||
assert.deepEqual(data.permissions, { allow: ["Bash(ls)"] });
|
||||
assert.equal(
|
||||
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the column of a workspace whose file it could not clean, to retry later", () => {
|
||||
const cwd = path.join(SUITE_ROOT, "broken-lane");
|
||||
const file = path.join(cwd, ".claude", "settings.local.json");
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, "{ not json");
|
||||
const lane = lanesLib.createLane({ title: "broken", cwd, router_profile: "9router" });
|
||||
clearLaneRouters();
|
||||
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
|
||||
assert.equal(
|
||||
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
|
||||
"9router"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* @file Tests GET/PUT /api/settings/codex-home and setCodexHome: the shared
|
||||
* home-dir guard, persistence to the settings file, the `env_owned` flag, and
|
||||
* the AI Router layer being re-written into the NEW home for every codex lane
|
||||
* on a router (a `codex -p <name>` against a home without `<name>.config.toml`
|
||||
* would fail at start); a home change from another site refused, and a
|
||||
* CLAUDE_HOME change naming the lanes whose router profile the new home's
|
||||
* store lacks. Everything lives in a temp dir.
|
||||
* a home change from another site refused. Everything lives in a temp dir.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -25,9 +21,6 @@ const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const express = require("express");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { applyRouterToLane } = require("../lib/router-apply");
|
||||
const { envOwned } = require("../lib/settings-env");
|
||||
const settingsRouter = require("../routes/settings");
|
||||
|
||||
@@ -97,27 +90,13 @@ describe("/api/settings/codex-home", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("switches, persists, and re-writes every codex lane's router layer into the new home", async () => {
|
||||
const cwd = path.join(ROOT, "lane");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const { name } = routers.createRouter({
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "Highest",
|
||||
});
|
||||
const lane = lanesLib.createLane({ title: "c", cwd, tool: "codex", router_profile: name });
|
||||
applyRouterToLane(lane, name);
|
||||
assert.ok(fs.existsSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`)));
|
||||
|
||||
it("switches and persists", async () => {
|
||||
const next = path.join(ROOT, "codex-b");
|
||||
fs.mkdirSync(next);
|
||||
const res = await request("PUT", "/api/settings/codex-home", { path: next });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.codex_home, next);
|
||||
assert.deepEqual(res.body.routers, { updated: 1, failed: [] });
|
||||
assert.equal(process.env.CODEX_HOME, next);
|
||||
assert.ok(fs.existsSync(path.join(next, `${name}.config.toml`)));
|
||||
assert.match(
|
||||
fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"),
|
||||
new RegExp(`^CODEX_HOME=${next}$`, "m")
|
||||
@@ -136,13 +115,4 @@ describe("/api/settings/codex-home", () => {
|
||||
assert.equal(res.status, 403);
|
||||
assert.notEqual(process.env.CODEX_HOME, next);
|
||||
});
|
||||
|
||||
it("a CLAUDE_HOME change names the lanes whose router the new store lacks", async () => {
|
||||
const next = path.join(ROOT, "claude-b");
|
||||
fs.mkdirSync(next);
|
||||
const res = await request("PUT", "/api/settings/claude-home", { path: next });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.routers.missing.length, 1);
|
||||
assert.match(res.body.routers.missing[0], /\(c\): 9router/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -584,6 +584,15 @@ 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.
|
||||
try {
|
||||
db.prepare("SELECT router FROM dashboard_runs LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN router TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
|
||||
// these are additive columns separate from `stage` (the declared stage), so
|
||||
// `stage`'s meaning is untouched and turning the feature off loses nothing.
|
||||
|
||||
@@ -605,6 +605,13 @@ 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
|
||||
// each workspace's settings.local.json (a no-op once done).
|
||||
try {
|
||||
require("./lib/router-apply").clearLaneRouters();
|
||||
} catch (err) {
|
||||
console.warn("clearing workspace routers failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
lane_id, tool, worktree, router
|
||||
) 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
|
||||
@lane_id, @tool, @worktree, @router
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -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`;
|
||||
started_at, ended_at, lane_id, tool, worktree, router`;
|
||||
|
||||
const listStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
@@ -115,6 +115,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,
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort by design (a live run must never fail because sqlite did),
|
||||
@@ -162,6 +163,24 @@ function getRun(id) {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionRouterStmt = db.prepare(`
|
||||
SELECT router 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.
|
||||
*/
|
||||
function routerForSession(sessionId) {
|
||||
try {
|
||||
return sessionRouterStmt.get({ sessionId })?.router || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const runningStmt = db.prepare(
|
||||
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||
);
|
||||
@@ -193,4 +212,4 @@ function reconcileOrphans(isAlive = () => false) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { recordRun, patchRun, listRuns, getRun, reconcileOrphans };
|
||||
module.exports = { recordRun, patchRun, listRuns, getRun, reconcileOrphans, routerForSession };
|
||||
|
||||
@@ -293,43 +293,6 @@ 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
|
||||
@@ -928,8 +891,6 @@ module.exports = {
|
||||
deleteLane,
|
||||
setStage,
|
||||
clearCodexPipelines,
|
||||
setRouterProfile,
|
||||
lanesByRouter,
|
||||
recordDetection,
|
||||
clearLane,
|
||||
recoverInterruptedProvisioning,
|
||||
|
||||
+18
-11
@@ -30,7 +30,7 @@
|
||||
*/
|
||||
|
||||
const tmux = require("./tmux");
|
||||
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
||||
const { resolveRouter, routerForSpawn } = require("./router-apply");
|
||||
const { desktopEnv } = require("./desktop-env");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const aiTools = require("./ai-tools");
|
||||
@@ -111,6 +111,7 @@ function buildClaudeArgv({
|
||||
initialPrompt,
|
||||
addDirs = [],
|
||||
systemPromptFile = null,
|
||||
routerArgv = [],
|
||||
}) {
|
||||
const argv = ["claude"];
|
||||
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
||||
@@ -118,6 +119,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);
|
||||
// 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
|
||||
@@ -158,7 +161,7 @@ function buildCodexArgv({
|
||||
resumeLast,
|
||||
bypass,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
routerArgv = [],
|
||||
addDirs = [],
|
||||
systemPrompt = null,
|
||||
systemPromptFile = null,
|
||||
@@ -167,7 +170,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(...routerArgvForSpawn({ tool: "codex", router_profile: routerProfile }));
|
||||
argv.push(...routerArgv);
|
||||
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
||||
else if (resumeLast) argv.push("resume", "--last");
|
||||
if (model) argv.push("-m", model);
|
||||
@@ -242,8 +245,9 @@ 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.routerProfile] AI Router profile the LANE runs against
|
||||
* (`lanes.router_profile`), never a request field.
|
||||
* @param {string} [args.router] AI Router 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`).
|
||||
*/
|
||||
function spawnRun(args) {
|
||||
const {
|
||||
@@ -263,7 +267,7 @@ function spawnRun(args) {
|
||||
resumeLast,
|
||||
bypass,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
router,
|
||||
} = args || {};
|
||||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
@@ -315,6 +319,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
|
||||
if (forkSession === true) {
|
||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||
if (entry.launcher !== "claude") {
|
||||
@@ -351,6 +356,7 @@ function spawnRun(args) {
|
||||
laneId,
|
||||
tool
|
||||
);
|
||||
const routerSpawn = routerForSpawn(tool, routerProfile);
|
||||
const argv = buildArgv(
|
||||
{
|
||||
addDirs,
|
||||
@@ -368,7 +374,7 @@ function spawnRun(args) {
|
||||
resumeLast,
|
||||
bypass: bypass === true,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
routerArgv: routerSpawn.argv,
|
||||
},
|
||||
entry
|
||||
);
|
||||
@@ -394,6 +400,7 @@ function spawnRun(args) {
|
||||
effort: effort || null,
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
worktree: worktree === true || inWorktree === true,
|
||||
router: routerProfile ? routerProfile.name : null,
|
||||
prompt: initialPrompt || "",
|
||||
status: "running",
|
||||
startedAt,
|
||||
@@ -407,9 +414,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 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.
|
||||
// A codex session on an AI Router 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.
|
||||
// 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
|
||||
@@ -430,7 +437,7 @@ function spawnRun(args) {
|
||||
// journals and the router 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() } : {}),
|
||||
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
||||
...routerSpawn.env,
|
||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||
};
|
||||
|
||||
|
||||
+149
-248
@@ -1,30 +1,31 @@
|
||||
/**
|
||||
* @file router-apply.js
|
||||
* @description Attaches an AI Router profile (see `ai-routers.js`) to one lane.
|
||||
* @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
|
||||
* into the user's project.
|
||||
*
|
||||
* 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.
|
||||
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
|
||||
* profile's eight `env` keys, `<data dir>/router-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.
|
||||
*
|
||||
* - **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
|
||||
* - **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 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.
|
||||
* the key reaches the pane through tmux's own `-e` under the variable the
|
||||
* layer's `env_key` names, 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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* Workspaces once carried a router of their own in `lanes.router_profile` and
|
||||
* `<cwd>/.claude/settings.local.json`; `clearLaneRouters` takes that out once.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -33,10 +34,9 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const routers = require("./ai-routers");
|
||||
const { patchSettings } = require("./cc-mutate");
|
||||
const { excludeFromGit } = require("./git-exclude");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { lanesByRouter, listLanes, setRouterProfile } = require("./lanes");
|
||||
const { ROUTER_CAPABLE } = require("./ai-tools");
|
||||
|
||||
/**
|
||||
* The env var Codex is told to read the router key from. One fixed name rather
|
||||
@@ -45,7 +45,7 @@ const { lanesByRouter, listLanes, setRouterProfile } = require("./lanes");
|
||||
*/
|
||||
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
|
||||
|
||||
/** The eight keys this feature owns in a lane's settings file, and nothing else. */
|
||||
/** The eight env keys a router sets for Claude Code. */
|
||||
function envForProfile(profile) {
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||
@@ -59,91 +59,27 @@ function envForProfile(profile) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
function claudeSettingsPath(name) {
|
||||
return path.join(getDataDir(), "router-settings", `${name}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The backup directory beside it, for the same reason plus one more: those
|
||||
* copies hold the API key too, and they are what a user sees as a dozen
|
||||
* untracked files appearing in their project while they switch routers.
|
||||
*/
|
||||
function excludeSettingsFromGit(laneDir) {
|
||||
excludeFromGit(laneDir, [".claude/settings.local.json", ".claude/.cc-config-backups/"]);
|
||||
function codexLayerPath(name) {
|
||||
return path.join(getCodexHome(), `${name}.config.toml`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
function writeSecret(file, body) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(file, body, { mode: 0o600 });
|
||||
fs.chmodSync(file, 0o600); // an existing file keeps its mode on write
|
||||
}
|
||||
|
||||
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 };
|
||||
function writeClaudeSettings(profile) {
|
||||
const file = claudeSettingsPath(profile.name);
|
||||
// Empty values are left out: a blank key in the command-line layer would
|
||||
// blank the user's own setting, not leave it alone.
|
||||
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
|
||||
writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** TOML string literal — router names and URLs are validated, quotes are not free. */
|
||||
@@ -155,8 +91,8 @@ function tomlString(value) {
|
||||
* 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`);
|
||||
function writeCodexLayer(profile) {
|
||||
const file = codexLayerPath(profile.name);
|
||||
const body =
|
||||
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
|
||||
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
||||
@@ -170,168 +106,133 @@ function applyToCodexLane(lane, profile) {
|
||||
// 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.
|
||||
// no longer supported`, a hard config error before the first request.
|
||||
`wire_api = "responses"\n`;
|
||||
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, body, { mode: 0o600 });
|
||||
return { tool: "codex", file, profile: profile.name };
|
||||
writeSecret(file, body);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"`
|
||||
* 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.
|
||||
*/
|
||||
const { ROUTER_CAPABLE } = require("./ai-tools");
|
||||
|
||||
function applyRouterToLane(lane, profileName) {
|
||||
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
||||
// Only Claude and Codex have an endpoint/model override to write; any other
|
||||
// AI tool runs on its vendor's own settings, so native is the one fit.
|
||||
if (!ROUTER_CAPABLE.has(lane.tool || "claude")) {
|
||||
if (profile.builtin) return { tool: lane.tool, file: null, profile: profile.name };
|
||||
throw Object.assign(new Error(`a ${lane.tool} workspace cannot use an AI Router`), {
|
||||
function resolveRouter(tool, name) {
|
||||
if (name == null || name === "" || name === routers.BUILTIN_NAME) return null;
|
||||
if (typeof name !== "string") {
|
||||
throw Object.assign(new Error("router must be a profile name"), { code: "EBADROUTER" });
|
||||
}
|
||||
let profile;
|
||||
try {
|
||||
profile = routers.getRouter(name);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOTFOUND") throw e;
|
||||
throw Object.assign(new Error(`no AI Router profile named "${name}"`), {
|
||||
code: "EBADROUTER",
|
||||
});
|
||||
}
|
||||
if (profile.builtin) return null;
|
||||
if (!ROUTER_CAPABLE.has(tool)) {
|
||||
throw Object.assign(new Error(`a ${tool} session cannot use an AI Router`), {
|
||||
code: "EUNSUPPORTED",
|
||||
});
|
||||
}
|
||||
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);
|
||||
return 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[]}
|
||||
* The router 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 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-write the router layer of every codex lane on a router, into the CURRENT
|
||||
* `$CODEX_HOME`. Called after CODEX_HOME moves: the layer `codex -p <name>`
|
||||
* loads lives there, so without this every such lane would fail to start.
|
||||
* Per-lane best effort, like `reapplyToLanes`.
|
||||
*
|
||||
* @returns {{updated:number, failed:string[]}}
|
||||
*/
|
||||
function reapplyCodexLayers() {
|
||||
const failed = [];
|
||||
let updated = 0;
|
||||
for (const lane of listLanes()) {
|
||||
if (lane.tool !== "codex" || !lane.router_profile || lane.router_profile === "native") continue;
|
||||
try {
|
||||
applyRouterToLane(lane, lane.router_profile);
|
||||
updated += 1;
|
||||
} catch (e) {
|
||||
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The lanes naming an AI Router profile the store no longer has — after
|
||||
* CLAUDE_HOME moved (the store is `<CLAUDE_HOME>/agenthub-routers/`), such a
|
||||
* lane starts on its base configuration (`readOrNull`), so the move says so.
|
||||
* @returns {string[]} `"<id> (<title>): <profile>"` per lane.
|
||||
*/
|
||||
function lanesWithMissingRouter() {
|
||||
return listLanes()
|
||||
.filter(
|
||||
(l) => l.router_profile && l.router_profile !== "native" && !readOrNull(l.router_profile)
|
||||
)
|
||||
.map((l) => `${l.id} (${l.title || l.cwd}): ${l.router_profile}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
function resumeRouter(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const name = require("./dashboard-runs").routerForSession(sessionId);
|
||||
if (!name) return null;
|
||||
try {
|
||||
fs.rmSync(path.join(getCodexHome(), `${profileName}.config.toml`), { force: true });
|
||||
routers.getRouter(name);
|
||||
return name;
|
||||
} catch {
|
||||
/* best effort — an orphan layer is a leak, not a failure to report */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Argv and env one spawn needs for `profile` (from `resolveRouter`): 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) {
|
||||
if (!profile) return { argv: [], env: {} };
|
||||
if (tool === "codex") {
|
||||
writeCodexLayer(profile);
|
||||
return {
|
||||
argv: ["-p", profile.name],
|
||||
env: profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {},
|
||||
};
|
||||
}
|
||||
// `=` form: one argv element, never read as an option of its own.
|
||||
return { argv: [`--settings=${writeClaudeSettings(profile)}`], env: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* After an edit: rewrite whichever of the profile's files exist, so a live
|
||||
* Claude session and the next Codex one see the new key/endpoint. Files that
|
||||
* do not exist yet are written at the next spawn.
|
||||
*/
|
||||
function refreshProfileFiles(name) {
|
||||
const profile = routers.getRouter(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
|
||||
* behind, a later profile that slugifies to the same name would also inherit
|
||||
* this one's `base_url` in Codex.
|
||||
*/
|
||||
function removeProfileFiles(name) {
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name)]) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
/* best effort — an orphan file is a leak, not a failure to report */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the old per-workspace router 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() {
|
||||
const { db } = require("../db");
|
||||
const { patchSettings } = require("./cc-mutate");
|
||||
const keys = Object.keys(envForProfile({ models: {} })).map((k) => `env.${k}`);
|
||||
const lanes = db
|
||||
.prepare("SELECT id, cwd, tool FROM lanes WHERE router_profile IS NOT NULL")
|
||||
.all();
|
||||
for (const lane of lanes) {
|
||||
try {
|
||||
if (lane.tool === "claude" && fs.existsSync(path.join(lane.cwd, ".claude"))) {
|
||||
patchSettings({ scope: "project-local", cwd: lane.cwd, set: {}, unset: keys });
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyRouterToLane,
|
||||
reapplyToLanes,
|
||||
reapplyCodexLayers,
|
||||
detachFromLanes,
|
||||
routerArgvForSpawn,
|
||||
routerEnvForSpawn,
|
||||
lanesWithMissingRouter,
|
||||
resolveRouter,
|
||||
resumeRouter,
|
||||
routerForSpawn,
|
||||
refreshProfileFiles,
|
||||
removeProfileFiles,
|
||||
clearLaneRouters,
|
||||
CODEX_KEY_ENV,
|
||||
};
|
||||
|
||||
+13
-17
@@ -1,14 +1,12 @@
|
||||
/**
|
||||
* @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.
|
||||
* **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`).
|
||||
*
|
||||
* 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.
|
||||
* 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`),
|
||||
* which an edit rewrites and a delete removes.
|
||||
*
|
||||
* No response on this router carries key material. `GET /` returns the store's
|
||||
* public view (`hasKey: true|false`), and there is deliberately no
|
||||
@@ -21,7 +19,7 @@
|
||||
const express = require("express");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const { reapplyToLanes, detachFromLanes } = require("../lib/router-apply");
|
||||
const { refreshProfileFiles, removeProfileFiles } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
@@ -79,26 +77,24 @@ router.post("/test", sameOriginGuard, async (req, res) => {
|
||||
// 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.
|
||||
// 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 || {});
|
||||
res.json({ ...saved, lanes: reapplyToLanes(saved.name) });
|
||||
refreshProfileFiles(saved.name);
|
||||
res.json(saved);
|
||||
} 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.
|
||||
// DELETE /api/ai-routers/:name — and the session files that copy its key.
|
||||
router.delete("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const removed = routers.removeRouter(req.params.name);
|
||||
res.json({ ...removed, lanes: detachFromLanes(req.params.name) });
|
||||
removeProfileFiles(req.params.name);
|
||||
res.json(removed);
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
|
||||
+7
-46
@@ -27,7 +27,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 { resumeRouter } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
@@ -735,48 +735,6 @@ 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: "workspace 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);
|
||||
// A pane that is already running does NOT fully adopt the switch. Claude
|
||||
// Code re-applies an `env` block to a live session when the settings file
|
||||
// changes, but the session's model was resolved at startup — so the OLD
|
||||
// router's model name keeps being sent to the NEW endpoint, which answers
|
||||
// "model not found". (Switching to `native` is the same story: the router's
|
||||
// model name reaches api.anthropic.com.) Nothing here can fix that from the
|
||||
// outside; the pane has to be restarted. Report it instead of letting the
|
||||
// switch look clean and then fail on the user's next prompt.
|
||||
const runId = runs.runIdForLane(lane.id);
|
||||
const live = runs.getRun(runId)?.status === "running";
|
||||
// Every other mutating lane route broadcasts; without it the client's
|
||||
// cached row keeps the OLD router_profile, and the next lane switch
|
||||
// re-reads that stale row and flips the picker back.
|
||||
broadcastLane(lane.id);
|
||||
res.json({ ...result, restart_required: live, run_id: live ? runId : null });
|
||||
} catch (err) {
|
||||
const status =
|
||||
{ ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400, EUNSUPPORTED: 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
|
||||
@@ -1880,9 +1838,12 @@ 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,
|
||||
// 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)),
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
effort: body.effort,
|
||||
|
||||
@@ -30,6 +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 router = Router();
|
||||
|
||||
@@ -321,8 +322,12 @@ 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,
|
||||
// The session's AI Router, 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),
|
||||
model: typeof body.model === "string" && body.model ? body.model : null,
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
|
||||
@@ -25,7 +25,6 @@ const APP_VERSION = (() => {
|
||||
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
|
||||
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
|
||||
const { envOwned } = require("../lib/settings-env");
|
||||
const { reapplyCodexLayers, lanesWithMissingRouter } = require("../lib/router-apply");
|
||||
const pricingSync = require("../lib/pricing-sync");
|
||||
|
||||
function getDbSize() {
|
||||
@@ -285,7 +284,7 @@ router.get("/export", (_req, res) => {
|
||||
// overridden again at the next start), PUT validates, applies and persists
|
||||
// (server/lib/settings-env.js). EBADHOME = too broad, INVALID_PATH = any other
|
||||
// bad path, 500 ESAVE = the settings file could not be written.
|
||||
function homeRoutes(route, key, field, get, set, after = () => ({})) {
|
||||
function homeRoutes(route, key, field, get, set) {
|
||||
router.get(route, (_req, res) => {
|
||||
res.json({ [field]: get(), env_owned: envOwned.has(key) });
|
||||
});
|
||||
@@ -308,20 +307,15 @@ function homeRoutes(route, key, field, get, set, after = () => ({})) {
|
||||
error: { code: bad ? err.code : "ESAVE", message: err.message },
|
||||
});
|
||||
}
|
||||
res.json({ ok: true, [field]: resolved, env_owned: envOwned.has(key), ...after() });
|
||||
res.json({ ok: true, [field]: resolved, env_owned: envOwned.has(key) });
|
||||
});
|
||||
}
|
||||
|
||||
// GET/PUT /api/settings/claude-home — a PUT names the lanes whose AI Router
|
||||
// profile the new home's store does not have (they would start on native).
|
||||
homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome, () => ({
|
||||
routers: { missing: lanesWithMissingRouter() },
|
||||
}));
|
||||
// GET/PUT /api/settings/codex-home — a PUT re-writes every codex lane's AI
|
||||
// Router layer into the new home, or `codex -p <name>` fails at the next start.
|
||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome, () => ({
|
||||
routers: reapplyCodexLayers(),
|
||||
}));
|
||||
// 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
|
||||
// into the current home at its spawn, so a move needs no repair here.
|
||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
||||
|
||||
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
||||
router.post("/cleanup", (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user