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:
2026-10-02 14:19:02 +07:00
parent 5084334d81
commit c507d37baf
55 changed files with 851 additions and 1992 deletions
+2 -2
View File
@@ -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 |
+12 -56
View File
@@ -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 });
+53
View File
@@ -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) => {
+149 -361
View File
@@ -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"
);
});
});
+2 -32
View File
@@ -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);
});
});
+9
View File
@@ -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.
+7
View File
@@ -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);
}
}
/**
+23 -4
View File
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id, tool, worktree
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 };
-39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+7 -2
View File
@@ -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)
+7 -13
View File
@@ -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) => {