5ba9e07d78
Closes #19, #20, #21, #27. #19 — a saved auth profile now reports how much of its account's quota is already spent, which is the only number that decides which one is worth switching to. Nothing under CLAUDE_HOME records it, so it comes from the same endpoint the CLI's own /usage reads, answered for whichever account's OAuth token is presented. It is a POST and a button, never a poll: every profile that is not the active one is normally expired, so answering means refreshing it first, and a refresh ROTATES the stored refresh token. The rotated pair is written straight back — dropping it would leave the profile holding a credential Anthropic has already retired, i.e. an account needing a fresh login. The token is read, put in an Authorization header, and never returned, logged or echoed; what leaves the server is two percentages and two reset times. #20 — a settings patch that changes nothing wrote nothing but still took a timestamped backup, so opening the config panel quietly grew a backup directory inside every lane. patchSettings now compares the rendered output with what is already on disk and returns early, fixed once where all callers route through. A lane's git exclude also picks up .claude/.cc-config-backups/ beside settings.local.json. #21 — the router apply route was the only mutating lane route that never broadcast its lane. The write reached disk and the column, but the client's cached row kept the old router_profile forever: the picker looked right until the user switched lanes and back, at which point the effect re-read the stale row and flipped the selection to the previous profile. One broadcastLane, plus a lane-switch guard so an apply that resolves after the user has moved on no longer paints its result onto whichever lane is now on screen. #27 — Add-lane in worktree mode left the dialog up after a clean setup with a dead confirm button and no obvious way out. It closes itself when nothing failed, and when something did, the summary stays and the confirm becomes a real Close.
324 lines
13 KiB
JavaScript
324 lines
13 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 { getCodexHome } = require("./codex-home");
|
|
const { lanesByRouter, setRouterProfile } = require("./lanes");
|
|
|
|
/**
|
|
* The env var Codex is told to read the router key from. One fixed name rather
|
|
* than one per profile: a Codex pane runs exactly one profile, so a second name
|
|
* would only create the chance of the wrong one being set.
|
|
*/
|
|
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
|
|
|
|
/** The eight keys this feature owns in a lane's settings file, and nothing else. */
|
|
function envForProfile(profile) {
|
|
return {
|
|
ANTHROPIC_BASE_URL: profile.baseUrl,
|
|
ANTHROPIC_AUTH_TOKEN: profile.apiKey,
|
|
ANTHROPIC_MODEL: profile.model,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models.opus,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models.sonnet,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.models.haiku,
|
|
ANTHROPIC_SMALL_FAST_MODEL: profile.models.smallFast,
|
|
CLAUDE_CODE_SUBAGENT_MODEL: profile.models.subagent,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read a profile, or resolve to null when it has since been deleted. Used on
|
|
* the spawn path, where a missing profile must degrade the lane to its base
|
|
* configuration rather than refuse to start it.
|
|
*/
|
|
function readOrNull(name) {
|
|
if (!name || name === routers.BUILTIN_NAME) return null;
|
|
try {
|
|
return routers.getRouter(name);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The lane's own git directory. For a normal clone that is `<cwd>/.git`; for a
|
|
* LINKED WORKTREE — which is what every managed lane is — `.git` is a pointer
|
|
* FILE, and joining `info/exclude` onto it names a path that cannot exist.
|
|
*
|
|
* @returns {string|null} absolute git dir, or null when this is not a repo
|
|
*/
|
|
function gitDirOf(laneDir) {
|
|
const dotGit = path.join(laneDir, ".git");
|
|
let stat;
|
|
try {
|
|
stat = fs.statSync(dotGit);
|
|
} catch {
|
|
return null; // not a git repo — a perfectly normal adopted directory
|
|
}
|
|
if (stat.isDirectory()) return dotGit;
|
|
const match = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
|
|
return match ? path.resolve(laneDir, match[1].trim()) : null;
|
|
}
|
|
|
|
/**
|
|
* Idempotently keep `.claude/settings.local.json` out of git, for two reasons
|
|
* that both matter:
|
|
*
|
|
* - `agenthub lanes reset` runs `git clean -fd` (`server/lib/worktree.js`).
|
|
* Without `-x` that spares IGNORED files only, so an untracked settings file
|
|
* is deleted and the lane silently loses its router.
|
|
* - The file holds an API key. Untracked means `git add -A` can commit it.
|
|
*
|
|
* Best effort: a lane that is not a repo, or a git dir that cannot be written,
|
|
* must not fail an otherwise-successful attach.
|
|
*/
|
|
function excludeSettingsFromGit(laneDir) {
|
|
// 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 `<cwd>/.claude/settings.local.json`. The
|
|
* caller-chosen-cwd rule it is written against is a ROUTE rule; `lane.cwd`
|
|
* comes from the DB row, which is the shape that rule allows.
|
|
*
|
|
* What it does not do is set a mode, because nothing else it writes holds a
|
|
* secret. This one does, so the file and the backup it just made of the
|
|
* previous one are both tightened afterwards.
|
|
*/
|
|
function applyToClaudeLane(lane, profile) {
|
|
// Native is not simply "no keys". The two halves go opposite ways:
|
|
//
|
|
// - The CONNECTION pair is BLANKED, not removed. An absent key at project
|
|
// scope means the user-level one applies, so dropping these would let a
|
|
// user-level router take over a lane that explicitly asked for native.
|
|
// - The MODEL names are REMOVED. Writing them would pin every lane — including
|
|
// one created by a user who never opened this tab — to whatever names this
|
|
// file shipped with, overriding the user-level choice that is the actual
|
|
// source of truth for native.
|
|
const env = envForProfile(profile);
|
|
const set = {};
|
|
let unset = [];
|
|
if (profile.builtin) {
|
|
const connection = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
|
for (const key of connection) set[`env.${key}`] = "";
|
|
unset = Object.keys(env)
|
|
.filter((key) => !connection.includes(key))
|
|
.map((key) => `env.${key}`);
|
|
} else {
|
|
for (const [key, value] of Object.entries(env)) set[`env.${key}`] = value;
|
|
}
|
|
|
|
const result = patchSettings({ scope: "project-local", cwd: lane.cwd, set, unset });
|
|
// Both branches, not just the router one. A native lane's blanked connection
|
|
// pair is load-bearing too — `git clean -fd` deleting it would let a
|
|
// user-level router quietly take over a lane that asked for native.
|
|
excludeSettingsFromGit(lane.cwd);
|
|
for (const file of [result.path, result.backupPath]) {
|
|
if (!file) continue;
|
|
try {
|
|
fs.chmodSync(file, 0o600);
|
|
} catch {
|
|
// Best effort: a write that landed must not be reported as a failure
|
|
// because the mode could not be tightened afterwards.
|
|
}
|
|
}
|
|
return { tool: "claude", file: result.path, profile: profile.name };
|
|
}
|
|
|
|
/** TOML string literal — router names and URLs are validated, quotes are not free. */
|
|
function tomlString(value) {
|
|
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
/**
|
|
* Write `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on
|
|
* top of the base config. The base file is never read or written here.
|
|
*/
|
|
function applyToCodexLane(lane, profile) {
|
|
const file = path.join(getCodexHome(), `${profile.name}.config.toml`);
|
|
const body =
|
|
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
|
|
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
|
`model = ${tomlString(profile.model)}\n` +
|
|
`model_provider = ${tomlString(profile.name)}\n\n` +
|
|
`[model_providers.${profile.name}]\n` +
|
|
`name = ${tomlString(profile.label)}\n` +
|
|
`base_url = ${tomlString(profile.baseUrl)}\n` +
|
|
// `env_key` only when there is a key to name. A local router (the 9Router
|
|
// reference config is exactly this) needs none, and naming a variable that
|
|
// is never set makes Codex refuse to start.
|
|
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
|
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is
|
|
// no longer supported`, a hard config error before the first request. The
|
|
// user's own working 9Router provider uses "responses" too.
|
|
`wire_api = "responses"\n`;
|
|
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, body, { mode: 0o600 });
|
|
return { tool: "codex", file, profile: profile.name };
|
|
}
|
|
|
|
/**
|
|
* Attach `profileName` to `lane`. Called once, when the lane is created.
|
|
*
|
|
* @param {{id:number, cwd:string, tool:string}} lane the lane row
|
|
* @param {string} profileName a saved profile's name, or `"native"`
|
|
*/
|
|
function applyRouterToLane(lane, profileName) {
|
|
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
|
if (lane.tool === "codex") {
|
|
// Native is the base config as it already stands. A layer that overrode
|
|
// nothing would still shadow whatever model the user set in the base.
|
|
if (profile.builtin) return { tool: "codex", file: null, profile: profile.name };
|
|
return applyToCodexLane(lane, profile);
|
|
}
|
|
return applyToClaudeLane(lane, profile);
|
|
}
|
|
|
|
/**
|
|
* Extra argv for a lane's spawn. Codex needs `-p <name>`; a Claude lane needs
|
|
* nothing, its settings file already carries the router.
|
|
* @returns {string[]}
|
|
*/
|
|
function routerArgvForSpawn(lane) {
|
|
if (!lane || lane.tool !== "codex") return [];
|
|
const profile = readOrNull(lane.router_profile);
|
|
return profile ? ["-p", profile.name] : [];
|
|
}
|
|
|
|
/**
|
|
* Extra environment for a lane's spawn — the key Codex's `env_key` names.
|
|
* @returns {Record<string,string>}
|
|
*/
|
|
function routerEnvForSpawn(lane) {
|
|
if (!lane || lane.tool !== "codex") return {};
|
|
const profile = readOrNull(lane.router_profile);
|
|
return profile && profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {};
|
|
}
|
|
|
|
/**
|
|
* Re-run the attach for every lane already on `profileName`.
|
|
*
|
|
* Attaching COPIES the profile — a claude lane's key and base URL live in its
|
|
* own settings file from then on. So an edit that stopped here would be inert
|
|
* where it matters most: rotating a leaked key in Settings would leave every
|
|
* claude lane authenticating with the revoked one, forever, with no in-product
|
|
* way to notice or fix it.
|
|
*
|
|
* Per-lane best effort: a lane whose directory is gone, or whose settings file
|
|
* a human has since broken, must not fail the edit for every other lane.
|
|
*
|
|
* @returns {{updated:number, failed:string[]}}
|
|
*/
|
|
function reapplyToLanes(profileName) {
|
|
const failed = [];
|
|
let updated = 0;
|
|
for (const lane of lanesByRouter(profileName)) {
|
|
try {
|
|
applyRouterToLane(lane, profileName);
|
|
updated += 1;
|
|
} catch (e) {
|
|
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
|
}
|
|
}
|
|
return { updated, failed };
|
|
}
|
|
|
|
/**
|
|
* Detach `profileName` from every lane using it, and delete the Codex layer
|
|
* file it wrote.
|
|
*
|
|
* Deleting a profile has to reach the lanes for the same reason an edit does,
|
|
* and more urgently: a claude lane keeps the deleted router's key and base URL
|
|
* in its settings file and would go on routing to a dead endpoint with a key
|
|
* the user believes they have removed. Each lane is reset to native — which now
|
|
* UNSETS those keys — and its `router_profile` cleared.
|
|
*
|
|
* The layer file matters too: left behind, a later profile that slugifies to
|
|
* the same name silently inherits this one's `base_url`.
|
|
*/
|
|
function detachFromLanes(profileName) {
|
|
const failed = [];
|
|
let updated = 0;
|
|
for (const lane of lanesByRouter(profileName)) {
|
|
try {
|
|
applyRouterToLane(lane, routers.BUILTIN_NAME);
|
|
setRouterProfile(lane.id, null);
|
|
updated += 1;
|
|
} catch (e) {
|
|
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
|
}
|
|
}
|
|
try {
|
|
fs.rmSync(path.join(getCodexHome(), `${profileName}.config.toml`), { force: true });
|
|
} catch {
|
|
/* best effort — an orphan layer is a leak, not a failure to report */
|
|
}
|
|
return { updated, failed };
|
|
}
|
|
|
|
module.exports = {
|
|
applyRouterToLane,
|
|
reapplyToLanes,
|
|
detachFromLanes,
|
|
routerArgvForSpawn,
|
|
routerEnvForSpawn,
|
|
CODEX_KEY_ENV,
|
|
};
|