/** * @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 `, which layers `$CODEX_HOME/.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ĩ */ const fs = require("node:fs"); const path = require("node:path"); const routers = require("./ai-routers"); const { patchSettings } = require("./cc-mutate"); const { getCodexHome } = require("./codex-home"); const { lanesByRouter, setRouterProfile } = require("./lanes"); /** * The env var Codex is told to read the router key from. One fixed name rather * than one per profile: a Codex pane runs exactly one profile, so a second name * would only create the chance of the wrong one being set. */ const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY"; /** The eight keys this feature owns in a lane's settings file, and nothing else. */ function envForProfile(profile) { return { ANTHROPIC_BASE_URL: profile.baseUrl, ANTHROPIC_AUTH_TOKEN: profile.apiKey, ANTHROPIC_MODEL: profile.model, ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models.sonnet, ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.models.haiku, ANTHROPIC_SMALL_FAST_MODEL: profile.models.smallFast, CLAUDE_CODE_SUBAGENT_MODEL: profile.models.subagent, }; } /** * Read a profile, or resolve to null when it has since been deleted. Used on * the spawn path, where a missing profile must degrade the lane to its base * configuration rather than refuse to start it. */ function readOrNull(name) { if (!name || name === routers.BUILTIN_NAME) return null; try { return routers.getRouter(name); } catch { return null; } } /** * The lane's own git directory. For a normal clone that is `/.git`; for a * LINKED WORKTREE — which is what every managed lane is — `.git` is a pointer * FILE, and joining `info/exclude` onto it names a path that cannot exist. * * @returns {string|null} absolute git dir, or null when this is not a repo */ function gitDirOf(laneDir) { const dotGit = path.join(laneDir, ".git"); let stat; try { stat = fs.statSync(dotGit); } catch { return null; // not a git repo — a perfectly normal adopted directory } if (stat.isDirectory()) return dotGit; const match = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m); return match ? path.resolve(laneDir, match[1].trim()) : null; } /** * Idempotently keep `.claude/settings.local.json` out of git, for two reasons * that both matter: * * - `agenthub lanes reset` runs `git clean -fd` (`server/lib/worktree.js`). * Without `-x` that spares IGNORED files only, so an untracked settings file * is deleted and the lane silently loses its router. * - The file holds an API key. Untracked means `git add -A` can commit it. * * Best effort: a lane that is not a repo, or a git dir that cannot be written, * must not fail an otherwise-successful attach. */ function excludeSettingsFromGit(laneDir) { // 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. const lines = [".claude/settings.local.json", ".claude/.cc-config-backups/"]; try { const gitDir = gitDirOf(laneDir); if (!gitDir) return; const excludePath = path.join(gitDir, "info", "exclude"); fs.mkdirSync(path.dirname(excludePath), { recursive: true }); let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; const present = new Set(existing.split("\n")); const missing = lines.filter((line) => !present.has(line)); if (missing.length === 0) return; if (existing && !existing.endsWith("\n")) existing += "\n"; fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`); } catch { /* best effort — see above */ } } /** * Merge the eight keys into the lane's settings, leaving every other key — * including every other env var — exactly as it was. * * `patchSettings` does this already and does it better than a local merge * would: per-key deep merge, a size ceiling, `__proto__`-style segments * refused, an atomic write, a timestamped backup, and a file that is not valid * JSON reported (`EBADJSON`) instead of overwritten. It resolves * `project-local` to exactly `/.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/.config.toml`, the layer `codex -p ` loads on * top of the base config. The base file is never read or written here. */ function applyToCodexLane(lane, profile) { const file = path.join(getCodexHome(), `${profile.name}.config.toml`); const body = `# Written by AgentHub — AI Router profile ${profile.label}.\n` + `# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` + `model = ${tomlString(profile.model)}\n` + `model_provider = ${tomlString(profile.name)}\n\n` + `[model_providers.${profile.name}]\n` + `name = ${tomlString(profile.label)}\n` + `base_url = ${tomlString(profile.baseUrl)}\n` + // `env_key` only when there is a key to name. A local router (the 9Router // reference config is exactly this) needs none, and naming a variable that // is never set makes Codex refuse to start. (profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") + // Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is // no longer supported`, a hard config error before the first request. The // user's own working 9Router provider uses "responses" too. `wire_api = "responses"\n`; fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, body, { mode: 0o600 }); return { tool: "codex", file, profile: profile.name }; } /** * Attach `profileName` to `lane`. Called once, when the lane is created. * * @param {{id:number, cwd:string, tool:string}} lane the lane row * @param {string} profileName a saved profile's name, or `"native"` */ function applyRouterToLane(lane, profileName) { const profile = routers.getRouter(profileName); // throws ENOTFOUND if (lane.tool === "codex") { // Native is the base config as it already stands. A layer that overrode // nothing would still shadow whatever model the user set in the base. if (profile.builtin) return { tool: "codex", file: null, profile: profile.name }; return applyToCodexLane(lane, profile); } return applyToClaudeLane(lane, profile); } /** * Extra argv for a lane's spawn. Codex needs `-p `; 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} */ 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, };