Files
AgentHub/server/lib/router-apply.js
T
nntrivi2001 77917203fd feat(ai-tools): catalog of AI coding CLIs, detection, enable/default, lanes for any tool (#40 A1)
server/lib/ai-tools.js loads server/data/ai-tools/*.json (38 tools, data from
Orca, MIT) plus DASHBOARD_AI_TOOLS_DIR, detects each CLI by walking PATH, and
keeps enable/default in <data dir>/ai-tools.json. shell-path.js merges the
login shell's PATH at startup and on Refresh. /api/ai-tools lists, refreshes
and toggles; /api/run/binary resolves through the catalog.

Lanes accept any catalog id (EDISABLED for a disabled one on creation, ENOTOOL
at start once a tool left the catalog). pty-run keeps the Claude/Codex
builders and builds every other tool's argv from its catalog file, pasting an
after-start prompt through a tmux buffer; every pane gets the hydrated PATH.
A per-run bypass switch appends the catalog's bypass args (Codex included).
POST /api/run now forwards sandbox/approval/resumeLast, which it dropped.
agenthub lanes add gains --tool.
2026-09-29 08:18:40 +07:00

299 lines
12 KiB
JavaScript

/**
* @file router-apply.js
* @description Attaches an AI Router profile (see `ai-routers.js`) to one lane.
*
* The two CLIs are configured in different places, and the difference is
* theirs, not a choice made here:
*
* - **Claude Code** reads an `env` block out of the settings layers, so a
* router is eight env keys merged into the lane's own
* `.claude/settings.local.json`. `settings.local.json` and not
* `settings.json` because the value written includes an API key, and the
* `.local` file is the one Claude Code's own convention keeps out of git.
*
* - **Codex** has no project-level config at all — `codex doctor` reports a
* single loaded file, `~/.codex/config.toml`. What it does have is
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of that
* base. So a profile becomes a layer file, the base is never touched, and
* the lane's spawn passes the flag. Codex takes the key from the environment
* variable named by `env_key`, so the key stays out of the file entirely and
* reaches the pane through tmux's own `-e`, never as typed text.
*
* Target directories come from the lane row, never from a request body — the
* same narrowing every other lane write path in this codebase uses.
*
* Attaching COPIES a profile, so the store is not the single source of truth
* once a lane exists. `reapplyToLanes`/`detachFromLanes` are what keep an edit
* or a delete in Settings from being silently inert on the lanes using it.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const routers = require("./ai-routers");
const { patchSettings } = require("./cc-mutate");
const { excludeFromGit } = require("./git-exclude");
const { getCodexHome } = require("./codex-home");
const { lanesByRouter, setRouterProfile } = require("./lanes");
/**
* The env var Codex is told to read the router key from. One fixed name rather
* than one per profile: a Codex pane runs exactly one profile, so a second name
* would only create the chance of the wrong one being set.
*/
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
/** The eight keys this feature owns in a lane's settings file, and nothing else. */
function envForProfile(profile) {
return {
ANTHROPIC_BASE_URL: profile.baseUrl,
ANTHROPIC_AUTH_TOKEN: profile.apiKey,
ANTHROPIC_MODEL: profile.model,
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.models.haiku,
ANTHROPIC_SMALL_FAST_MODEL: profile.models.smallFast,
CLAUDE_CODE_SUBAGENT_MODEL: profile.models.subagent,
};
}
/**
* Read a profile, or resolve to null when it has since been deleted. Used on
* the spawn path, where a missing profile must degrade the lane to its base
* configuration rather than refuse to start it.
*/
function readOrNull(name) {
if (!name || name === routers.BUILTIN_NAME) return null;
try {
return routers.getRouter(name);
} catch {
return null;
}
}
/**
* 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/"]);
}
/**
* Merge the eight keys into the lane's settings, leaving every other key —
* including every other env var — exactly as it was.
*
* `patchSettings` does this already and does it better than a local merge
* would: per-key deep merge, a size ceiling, `__proto__`-style segments
* refused, an atomic write, a timestamped backup, and a file that is not valid
* JSON reported (`EBADJSON`) instead of overwritten. It resolves
* `project-local` to exactly `<cwd>/.claude/settings.local.json`. The
* caller-chosen-cwd rule it is written against is a ROUTE rule; `lane.cwd`
* comes from the DB row, which is the shape that rule allows.
*
* What it does not do is set a mode, because nothing else it writes holds a
* secret. This one does, so the file and the backup it just made of the
* previous one are both tightened afterwards.
*/
function applyToClaudeLane(lane, profile) {
// Native is not simply "no keys". The two halves go opposite ways:
//
// - The CONNECTION pair is BLANKED, not removed. An absent key at project
// scope means the user-level one applies, so dropping these would let a
// user-level router take over a lane that explicitly asked for native.
// - The MODEL names are REMOVED. Writing them would pin every lane — including
// one created by a user who never opened this tab — to whatever names this
// file shipped with, overriding the user-level choice that is the actual
// source of truth for native.
const env = envForProfile(profile);
const set = {};
let unset = [];
if (profile.builtin) {
const connection = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
for (const key of connection) set[`env.${key}`] = "";
unset = Object.keys(env)
.filter((key) => !connection.includes(key))
.map((key) => `env.${key}`);
} else {
for (const [key, value] of Object.entries(env)) set[`env.${key}`] = value;
}
const result = patchSettings({ scope: "project-local", cwd: lane.cwd, set, unset });
// Both branches, not just the router one. A native lane's blanked connection
// pair is load-bearing too — `git clean -fd` deleting it would let a
// user-level router quietly take over a lane that asked for native.
excludeSettingsFromGit(lane.cwd);
for (const file of [result.path, result.backupPath]) {
if (!file) continue;
try {
fs.chmodSync(file, 0o600);
} catch {
// Best effort: a write that landed must not be reported as a failure
// because the mode could not be tightened afterwards.
}
}
return { tool: "claude", file: result.path, profile: profile.name };
}
/** TOML string literal — router names and URLs are validated, quotes are not free. */
function tomlString(value) {
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
/**
* Write `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on
* top of the base config. The base file is never read or written here.
*/
function applyToCodexLane(lane, profile) {
const file = path.join(getCodexHome(), `${profile.name}.config.toml`);
const body =
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
`model = ${tomlString(profile.model)}\n` +
`model_provider = ${tomlString(profile.name)}\n\n` +
`[model_providers.${profile.name}]\n` +
`name = ${tomlString(profile.label)}\n` +
`base_url = ${tomlString(profile.baseUrl)}\n` +
// `env_key` only when there is a key to name. A local router (the 9Router
// reference config is exactly this) needs none, and naming a variable that
// is never set makes Codex refuse to start.
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is
// no longer supported`, a hard config error before the first request. The
// user's own working 9Router provider uses "responses" too.
`wire_api = "responses"\n`;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body, { mode: 0o600 });
return { tool: "codex", file, profile: profile.name };
}
/**
* Attach `profileName` to `lane`. Called once, when the lane is created.
*
* @param {{id:number, cwd:string, tool:string}} lane the lane row
* @param {string} profileName a saved profile's name, or `"native"`
*/
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} lane 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);
}
/**
* Extra argv for a lane's spawn. Codex needs `-p <name>`; a Claude lane needs
* nothing, its settings file already carries the router.
* @returns {string[]}
*/
function routerArgvForSpawn(lane) {
if (!lane || lane.tool !== "codex") return [];
const profile = readOrNull(lane.router_profile);
return profile ? ["-p", profile.name] : [];
}
/**
* Extra environment for a lane's spawn — the key Codex's `env_key` names.
* @returns {Record<string,string>}
*/
function routerEnvForSpawn(lane) {
if (!lane || lane.tool !== "codex") return {};
const profile = readOrNull(lane.router_profile);
return profile && profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {};
}
/**
* Re-run the attach for every lane already on `profileName`.
*
* Attaching COPIES the profile — a claude lane's key and base URL live in its
* own settings file from then on. So an edit that stopped here would be inert
* where it matters most: rotating a leaked key in Settings would leave every
* claude lane authenticating with the revoked one, forever, with no in-product
* way to notice or fix it.
*
* Per-lane best effort: a lane whose directory is gone, or whose settings file
* a human has since broken, must not fail the edit for every other lane.
*
* @returns {{updated:number, failed:string[]}}
*/
function reapplyToLanes(profileName) {
const failed = [];
let updated = 0;
for (const lane of lanesByRouter(profileName)) {
try {
applyRouterToLane(lane, profileName);
updated += 1;
} catch (e) {
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
}
}
return { updated, failed };
}
/**
* Detach `profileName` from every lane using it, and delete the Codex layer
* file it wrote.
*
* Deleting a profile has to reach the lanes for the same reason an edit does,
* and more urgently: a claude lane keeps the deleted router's key and base URL
* in its settings file and would go on routing to a dead endpoint with a key
* the user believes they have removed. Each lane is reset to native — which now
* UNSETS those keys — and its `router_profile` cleared.
*
* The layer file matters too: left behind, a later profile that slugifies to
* the same name silently inherits this one's `base_url`.
*/
function detachFromLanes(profileName) {
const failed = [];
let updated = 0;
for (const lane of lanesByRouter(profileName)) {
try {
applyRouterToLane(lane, routers.BUILTIN_NAME);
setRouterProfile(lane.id, null);
updated += 1;
} catch (e) {
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
}
}
try {
fs.rmSync(path.join(getCodexHome(), `${profileName}.config.toml`), { force: true });
} catch {
/* best effort — an orphan layer is a leak, not a failure to report */
}
return { updated, failed };
}
module.exports = {
applyRouterToLane,
reapplyToLanes,
detachFromLanes,
routerArgvForSpawn,
routerEnvForSpawn,
CODEX_KEY_ENV,
};