feat(settings): add AI Router profiles and attach one per lane

A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.

The two CLIs are configured differently, and the difference is theirs:

  - a claude lane gets eight `env` keys merged into its own
    `.claude/settings.local.json` (`.local` because the value holds a key);
  - a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
    `codex -p <name>` loads — because Codex has NO project-level config.
    Its base `config.toml` is never touched.

Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).

Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.

Three things this had to get right, each found by review and verified
against the real tools rather than assumed:

  - `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
    supported"), so every codex lane on a router would have failed to start.
    The layer writes "responses", and writes `env_key` only when the profile
    actually has a key — naming an unset variable is itself a startup error.
  - The key must never be TYPED into a pane. `tmux.sendCommand` used
    `send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
    every attached browser and bash appends to history; it now takes over the
    idle pane with `respawn-pane -k -e`, passing env through argv the way
    `newSession` always has. Verified on tmux 3.4: multi-element argv arrives
    intact and the pane renders no trace of the value.
  - Attaching COPIES a profile, so editing or deleting one in Settings has to
    reach the lanes already using it. An edit re-applies it (a rotated key
    would otherwise never reach the lane still holding the revoked one); a
    delete resets those lanes to native and removes the Codex layer file.
    Both report per-lane failures, which the tab surfaces.

`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.

DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
This commit is contained in:
2026-09-14 16:04:57 +07:00
parent fc7b58f15b
commit 0a86dfb8c5
27 changed files with 2676 additions and 53 deletions
+319
View File
@@ -0,0 +1,319 @@
/**
* @file ai-routers.js
* @description Named AI Router profiles — an endpoint (9Router, OpenRouter, a
* self-hosted gateway) plus the model names to use against it, saved once and
* attached to a lane when that lane is created.
*
* A profile is exactly what the two CLIs need to talk to a router and nothing
* more: `baseUrl`, `apiKey`, a default `model`, and one slot per tier mirroring
* the env keys Claude Code reads. Per-tier slots because a router may expose a
* different id per tier — and the `model` field fills every one of them,
* because the common case is a single symbolic name for everything.
*
* Profiles are stored one JSON file per profile under
* `<CLAUDE_HOME>/agenthub-routers/` (dir 0700, files 0600), the same shape and
* the same reasoning as `cc-profiles.js`: they hold a live API key, so **no
* route ever returns key material** — `listRouters` reports `hasKey` and
* nothing else about it.
*
* `native` is a built-in profile rather than a branch in the caller: it carries
* empty connection fields and the vendor's own model names, so "switch back to
* native" is the same single write path as "switch to a router". It cannot be
* edited, removed, or shadowed by a saved profile.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
/**
* A profile's `name` is its filename, so the charset is deliberately narrow.
* What the user reads and edits is `label`, which accepts spaces and diacritics
* precisely because it never touches a path. Same split as cc-profiles.js.
*/
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
const LABEL_MIN = 3;
const LABEL_MAX = 60;
/** The per-tier model overrides Claude Code reads, in the order the UI shows them. */
const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
const BUILTIN_NAME = "native";
/**
* The vendor's own defaults, used when a lane is set back to native. Empty
* connection fields rather than absent ones: the native reference settings.json
* sets both to "", and an absent key would let a user-level router base URL
* leak into a lane that asked for native.
*/
const NATIVE = Object.freeze({
name: BUILTIN_NAME,
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
apiKey: "",
model: "claude-sonnet-5",
models: Object.freeze({
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
}),
});
function err(code, message) {
const e = new Error(message);
e.code = code;
return e;
}
function routersDir() {
return path.join(getClaudeHome(), "agenthub-routers");
}
function routerFile(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
}
return path.join(routersDir(), `${name}.json`);
}
function readJson(file) {
let raw;
try {
raw = fs.readFileSync(file, "utf8");
} catch {
return null;
}
try {
return JSON.parse(raw);
} catch {
throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`);
}
}
/** Write JSON at 0600 through a temp file, so a crash never truncates the original. */
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.agenthub-tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tmp, file);
}
/** Reduce any text to the narrow NAME_RE charset, for use as a filename. */
function slugify(text) {
const base = String(text || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // drop the combining accents NFD split off
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
return NAME_RE.test(base) ? base : "router";
}
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
function freeName(base) {
let name = base;
for (
let n = 2;
name === BUILTIN_NAME || fs.existsSync(path.join(routersDir(), `${name}.json`));
) {
name = `${base}-${n}`;
n += 1;
}
return name;
}
function normalizeLabel(label) {
const clean = typeof label === "string" ? label.trim() : "";
if (clean.length < LABEL_MIN || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) {
throw err(
"EBADLABEL",
`label must be ${LABEL_MIN}-${LABEL_MAX} characters and no control characters`
);
}
return clean;
}
/**
* A router endpoint is an http(s) URL and nothing else. A `file:` or
* `javascript:` base URL is never a router — it is a mistake, or an attempt to
* point a CLI somewhere it should not go.
*/
function normalizeBaseUrl(baseUrl) {
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
if (!raw) throw err("EBADURL", "baseUrl is required");
let parsed;
try {
parsed = new URL(raw);
} catch {
throw err("EBADURL", `not a valid URL: ${raw}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw err("EBADURL", "baseUrl must be http:// or https://");
}
return raw.replace(/\/+$/, "");
}
/** A model name is free text — a router names its own models — but not blank. */
function normalizeModel(model) {
const clean = typeof model === "string" ? model.trim() : "";
if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) {
throw err("EBADMODEL", "model must be 1-200 characters and no control characters");
}
return clean;
}
/**
* Fill every slot from `model`, keeping any the caller set explicitly. This
* is what makes "one symbolic name for every tier" — the 9Router case — a
* single field to type instead of six.
*/
function normalizeModels(model, models) {
const given = models && typeof models === "object" ? models : {};
const out = {};
for (const slot of MODEL_SLOTS) {
out[slot] = given[slot] === undefined ? model : normalizeModel(given[slot]);
}
return out;
}
function assertNotBuiltin(name) {
if (name === BUILTIN_NAME) {
throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`);
}
}
/** Read one stored profile (key included), or null when it does not exist. */
function readRouter(name) {
const stored = readJson(routerFile(name));
return stored && typeof stored === "object" ? stored : null;
}
/** The browser-safe view: everything except the key, plus whether one is set. */
function publicView(profile) {
const { apiKey, ...rest } = profile;
return { ...rest, hasKey: Boolean(apiKey) };
}
/**
* Every saved profile, with the built-in native one first.
* @returns `{ items }` — key material is never included.
*/
function listRouters() {
let names = [];
try {
names = fs
.readdirSync(routersDir())
.filter((f) => f.endsWith(".json"))
.map((f) => f.slice(0, -5));
} catch {
/* nothing saved yet */
}
const items = [publicView(NATIVE)];
for (const name of names.sort()) {
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
let stored;
try {
stored = readRouter(name);
} catch {
continue; // a corrupt profile file is skipped, not fatal for the list
}
if (!stored) continue;
items.push(publicView({ ...stored, name, builtin: false }));
}
return { items };
}
/**
* One profile INCLUDING its key — for the server's own writers only. No route
* may return this shape; `listRouters` is what the browser sees.
*/
function getRouter(name) {
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
const stored = readRouter(name);
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
return { ...stored, name, builtin: false };
}
/**
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
* models?:Record<string,string>}} input
* @returns the browser-safe view of what was saved
*/
function createRouter(input = {}) {
const label = normalizeLabel(input.label);
const slug = slugify(label);
assertNotBuiltin(slug);
const model = normalizeModel(input.model);
const profile = {
label,
baseUrl: normalizeBaseUrl(input.baseUrl),
apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "",
model,
models: normalizeModels(model, input.models),
savedAt: new Date().toISOString(),
};
const name = freeName(slug);
writeJson(routerFile(name), profile);
return publicView({ ...profile, name, builtin: false });
}
/**
* Merge changes into a saved profile. The `name` never changes — a lane stores
* it, and renaming would orphan that lane. An omitted `apiKey` keeps the stored
* one: the edit form cannot show a key, so it cannot send one back either.
*/
function updateRouter(name, changes = {}) {
assertNotBuiltin(name);
const stored = readRouter(name);
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
const model = changes.model === undefined ? stored.model : normalizeModel(changes.model);
const next = {
...stored,
label: changes.label === undefined ? stored.label : normalizeLabel(changes.label),
baseUrl: changes.baseUrl === undefined ? stored.baseUrl : normalizeBaseUrl(changes.baseUrl),
apiKey:
typeof changes.apiKey === "string" && changes.apiKey.trim()
? changes.apiKey.trim()
: stored.apiKey,
model,
models:
changes.model === undefined && changes.models === undefined
? stored.models
: normalizeModels(model, changes.models ?? stored.models),
savedAt: new Date().toISOString(),
};
writeJson(routerFile(name), next);
return publicView({ ...next, name, builtin: false });
}
function removeRouter(name) {
assertNotBuiltin(name);
const file = routerFile(name);
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no router profile named '${name}'`);
fs.rmSync(file);
return { removed: name };
}
module.exports = {
listRouters,
getRouter,
createRouter,
updateRouter,
removeRouter,
MODEL_SLOTS,
BUILTIN_NAME,
NATIVE,
};
+42 -1
View File
@@ -148,6 +148,7 @@ function createLane({
base_branch = null,
slug = null,
tool = "claude",
router_profile = null,
} = {}) {
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
@@ -157,7 +158,7 @@ function createLane({
validateTool(tool);
const info = db
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run(
title,
@@ -169,6 +170,7 @@ function createLane({
base_branch,
slug,
tool,
router_profile || null,
nowIso()
);
return getLane(info.lastInsertRowid);
@@ -205,6 +207,43 @@ 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
@@ -752,6 +791,8 @@ module.exports = {
updateLane,
deleteLane,
setStage,
setRouterProfile,
lanesByRouter,
recordDetection,
clearLane,
recoverInterruptedProvisioning,
+18 -2
View File
@@ -25,6 +25,7 @@
*/
const tmux = require("./tmux");
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
let dashboardRuns = null;
try {
@@ -98,8 +99,13 @@ function buildCodexArgv({
resumeSessionId,
resumeLast,
initialPrompt,
routerProfile,
}) {
const argv = ["codex"];
// 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 }));
if (resumeSessionId) argv.push("resume", resumeSessionId);
else if (resumeLast) argv.push("resume", "--last");
if (model) argv.push("-m", model);
@@ -127,6 +133,8 @@ function buildArgv(args) {
* @param {string} [args.resumeSessionId]
* @param {boolean} [args.resumeLast]
* @param {string} [args.initialPrompt]
* @param {string} [args.routerProfile] AI Router profile the LANE runs against
* (`lanes.router_profile`), never a request field.
*/
function spawnRun(args) {
const {
@@ -141,6 +149,7 @@ function spawnRun(args) {
resumeSessionId,
resumeLast,
initialPrompt,
routerProfile,
} = args || {};
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer");
@@ -208,6 +217,7 @@ function spawnRun(args) {
resumeSessionId,
resumeLast,
initialPrompt,
routerProfile,
});
const record = () => {
@@ -241,7 +251,13 @@ 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).
const env = { AGENTHUB_LANE_ID: String(laneId) };
// 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.
const env = {
AGENTHUB_LANE_ID: String(laneId),
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
};
if (!tmux.hasSession(id)) {
tmux.newSession({ name: id, cwd, argv, env });
@@ -253,7 +269,7 @@ function spawnRun(args) {
// `--resume` and an initial prompt would never be typed, while the API
// still answered 200. Run the argv in the pane the user already sees
// instead of erroring or opening a second session.
tmux.sendCommand(id, argv, env);
tmux.sendCommand(id, argv, env, cwd);
record();
}
// Pane is running something (a live `claude`, an editor, a build): adopt
+320
View File
@@ -0,0 +1,320 @@
/**
* @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) {
const line = ".claude/settings.local.json";
try {
const gitDir = gitDirOf(laneDir);
if (!gitDir) return;
const excludePath = path.join(gitDir, "info", "exclude");
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
if (existing.split("\n").includes(line)) return;
fs.writeFileSync(
excludePath,
existing && !existing.endsWith("\n") ? `${existing}\n${line}\n` : `${existing}${line}\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,
};
+26 -22
View File
@@ -6,10 +6,11 @@
* `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes shell`). Never builds a
* shell string — every call is `execFileSync("tmux", [...argv])` with an
* explicit argument array (matches this repo's rule for git in worktree.js).
* The one place a command line is composed is `sendCommand`, which types into
* an existing pane's shell: there the shell IS the consumer, so every argument
* is POSIX single-quoted first and sent with `send-keys -l` (literal).
* `sendText` is the same primitive for the opposite case — a pane running an
* No call composes a shell command line at all, `sendCommand` included: it
* takes over an idle pane with `respawn-pane -- <argv>` rather than typing a
* quoted line into that pane's shell, which keeps an API key passed in `env`
* out of the pane's rendered text, its scrollback and the user's shell history.
* `sendText` is the one primitive that does send keystrokes — a pane running an
* interactive chat program that reads stdin as a message, not a shell.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -88,27 +89,30 @@ function paneCommand(name) {
}
}
/** POSIX single-quote escaping — the pane is a shell, so argv must be quoted. */
function shellQuote(arg) {
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
}
/**
* Type `argv` into an existing session's pane and press Enter. Only ever
* called when the pane sits at a shell prompt (see `paneCommand`); `-l` sends
* the string literally so no character is read as a tmux key name.
* Run `argv` in an existing session's pane. Only ever called when the pane sits
* at a shell prompt (see `paneCommand`), so what `-k` kills is an idle shell.
*
* `env` rides along as a shell assignment prefix on the SAME line — tmux's own
* `-e` applies only at session creation, and a shell that is already running
* takes its environment from the command line. The name is never quoted (bash
* only recognizes an assignment when the `=` is unquoted); the value is.
* `respawn-pane` and not `send-keys`, because `env` may carry a secret — an AI
* Router's API key, which Codex reads from the variable its `env_key` names.
* Typing it would put a `KEY='sk-live-…' codex …` line into the pane's rendered
* text, which the dashboard streams to every attached browser, into the tmux
* scrollback, and into the user's shell history. `-e` hands the value to the
* tmux server through argv instead, exactly as `newSession` already does, and
* the pane never renders it. Running argv directly also means no shell to quote
* for, so the whole class of quoting bugs goes with it.
*/
function sendCommand(name, argv, env) {
const prefix = Object.entries(env || {})
.map(([key, value]) => `${key}=${shellQuote(value)} `)
.join("");
execImpl(["send-keys", "-t", name, "-l", prefix + argv.map(shellQuote).join(" ")]);
execImpl(["send-keys", "-t", name, "Enter"]);
function sendCommand(name, argv, env, cwd) {
execImpl([
"respawn-pane",
"-k",
"-t",
name,
...(cwd ? ["-c", cwd] : []),
...envFlags(env),
"--",
...argv,
]);
}
/**