/** * @file proxy-apply.js * @description Starts ONE session on an AI Proxy profile (see `ai-proxies.js`). * A proxy 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** gets `--settings=`: a settings file holding the * profile's eight `env` keys (plus `modelPicker` rows when the profile * sets `behavesAs`), `/proxy-settings/.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 proxies, and the key stays out of argv — only the path is there. * * - **Codex** has no project-level config at all. What it does have is * `-p `, which layers `$CODEX_HOME/.config.toml` on top of the * base. So a profile becomes a layer file, the base is never touched, and * the key reaches the pane through tmux's own `-e` under the variable the * layer's `env_key` names, never as typed text. Its `base_url` is the * profile's root plus `/v1` (Codex appends `/responses`); Claude Code's * `ANTHROPIC_BASE_URL` is the bare root (it appends `/v1/messages`). * * 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. * * **Server mode never hands a pane the key** (S3 plan, P1b): both files then * name AgentHub's own loopback gateway (`proxy-gateway.js`) as the endpoint and * carry no key; the pane's own token is its key, through tmux `-e` * (`ANTHROPIC_AUTH_TOKEN` for Claude, `AGENTHUB_PROXY_KEY` for Codex), and the * gateway swaps in the real one. Local mode is unchanged. * * Workspaces once carried a proxy (then called a router) of their own in * `lanes.router_profile` and `/.claude/settings.local.json`; * `clearLaneProxies` takes that out once. * * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const proxies = require("./ai-proxies"); const { getDataDir } = require("./claude-home"); const { getCodexHome } = require("./codex-home"); const { PROXY_CAPABLE } = require("./ai-tools"); const { gatewayBase } = require("./proxy-gateway"); /** Server mode: panes reach a proxy only through the key gateway. */ const viaGateway = () => require("./auth").isServerMode(); /** * The env var Codex is told to read the proxy 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_PROXY_KEY"; /** * The eight env keys a proxy sets for Claude Code. Through the gateway the * base URL is the gateway's and the token is left out (the pane's own token * comes through tmux `-e`). */ function envForProfile(profile) { const gw = viaGateway(); return { ANTHROPIC_BASE_URL: gw ? gatewayBase(profile.name) : profile.baseUrl, ANTHROPIC_AUTH_TOKEN: gw ? "" : 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, }; } function claudeSettingsPath(name) { return path.join(getDataDir(), "proxy-settings", `${name}.json`); } function codexLayerPath(name) { return path.join(getCodexHome(), `${name}.config.toml`); } 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 } /** * `modelPicker` rows telling Claude Code which known model each of the proxy's * own names behaves as — without one, a name like 9Router's `agent` draws an * "isn't described by this version's model catalog" warning and a 200k * compact window (issue #56). Only when the profile sets `behavesAs`: this * layer's `modelPicker` replaces the user's own for the session. A bare * `claude-*` id is skipped, since Claude Code already knows it. */ function modelPicker(profile) { if (!profile.behavesAs) return {}; const names = new Set([profile.model, ...Object.values(profile.models || {})]); const options = [...names] .filter((m) => m && !m.startsWith("claude-")) .map((model) => ({ model, behavesAs: profile.behavesAs })); return options.length ? { modelPicker: { options } } : {}; } /** * The `--settings` file of a Claude pane. Through the gateway (server mode) * it is the PANE's own (`--.json`) and carries the pane token * as ANTHROPIC_AUTH_TOKEN: the command-line layer outranks the user's and the * project's settings, so an `env.ANTHROPIC_*` there cannot replace it (the * process env could be). A member's goes in their handoff dir, as them, 0600. */ function writeClaudeSettings(profile, member = null, pane = null) { const name = pane ? `${profile.name}--${pane.runId}` : profile.name; const file = member ? path.join(require("./run-as").handoffDir(member), `proxy-${name}.json`) : claudeSettingsPath(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), ...(pane ? { ANTHROPIC_AUTH_TOKEN: pane.token } : {}), }).filter(([, v]) => v) ); const body = `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`; if (member) return require("./run-as").handoffWrite(member, path.basename(file), body, "private"); writeSecret(file, body); return file; } /** TOML string literal — proxy 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 writeCodexLayer(profile) { const file = codexLayerPath(profile.name); const gw = viaGateway(); const body = `# Written by AgentHub — AI Proxy 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(`${gw ? gatewayBase(profile.name) : profile.baseUrl}/v1`)}\n` + // `env_key` only when there is a key to name. A local proxy (the 9Router // reference config is exactly this) needs none, and naming a variable that // is never set makes Codex refuse to start. (gw || 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. `wire_api = "responses"\n`; writeSecret(file, body); return file; } /** The Codex layer's settings as `-c` overrides (a member's pane; server mode). */ function codexOverrides(profile) { const provider = `{name=${tomlString(profile.label)},` + `base_url=${tomlString(`${gatewayBase(profile.name)}/v1`)},` + `env_key=${tomlString(CODEX_KEY_ENV)},wire_api="responses"}`; return [ ...["-c", `model_providers.${profile.name}=${provider}`], ...["-c", `model_provider=${tomlString(profile.name)}`], ...["-c", `model=${tomlString(profile.model)}`], ]; } /** * The profile a new session asked for, checked against its tool and its * workspace's owner. `null`, `""` and `"native"` mean the vendor's own API and * resolve to null. A personal profile resolves only for a workspace its owner * owns; for anyone else it does not exist. * @param {number|null} [ownerId] the workspace's `lanes.owner_id` * @throws EBADPROXY for a profile the store does not have (or this owner may * not use), EUNSUPPORTED for a proxy on a tool that has no endpoint to point * at one. */ function resolveProxy(tool, name, ownerId = null) { if (name == null || name === "" || name === proxies.BUILTIN_NAME) return null; if (typeof name !== "string") { throw Object.assign(new Error("proxy must be a profile name"), { code: "EBADPROXY" }); } let profile; try { const owner = proxies.ownerOfName(name); if (owner != null && owner !== ownerId) { throw Object.assign(new Error("not this owner's"), { code: "ENOTFOUND" }); } profile = proxies.getProxy(name); } catch (e) { if (e.code !== "ENOTFOUND") throw e; throw Object.assign(new Error(`no AI Proxy profile named "${name}"`), { code: "EBADPROXY", }); } if (profile.builtin) return null; if (!PROXY_CAPABLE.has(tool)) { throw Object.assign(new Error(`a ${tool} session cannot use an AI Proxy`), { code: "EUNSUPPORTED", }); } return profile; } /** * The proxy a resume that names none goes back to: the one its session last * ran on, while that profile still exists and the workspace's owner may use * it — a deleted one, or another user's personal one, resumes on native. */ function resumeProxy(sessionId, ownerId = null) { if (!sessionId) return null; const name = require("./dashboard-runs").proxyForSession(sessionId); if (!name) return null; const owner = proxies.ownerOfName(name); if (owner != null && owner !== ownerId) return null; try { proxies.getProxy(name); return name; } catch { return null; } } /** * Argv and env one spawn needs for `profile` (from `resolveProxy`): Claude's * `--settings=`, Codex's `-p ` plus the key. Writes the file. In * server mode the "key" is the pane's own token (`paneToken`), which only the * gateway accepts. * @returns {{argv: string[], env: Record}} */ function proxyForSpawn(tool, profile, paneToken = null, member = null, runId = null) { if (!profile) return { argv: [], env: {} }; const gw = viaGateway(); if (gw && !paneToken) { // Server mode always mints one (auth.newPaneToken); without it the pane // could only be given the real key, which this mode never does. throw Object.assign(new Error("no pane token for the AI Proxy gateway"), { code: "EPROXY", }); } if (tool === "codex") { const key = gw ? paneToken : profile.apiKey; const env = key ? { [CODEX_KEY_ENV]: key } : {}; // S3: a member's CODEX_HOME is theirs (no layer file of ours there): the // same provider as `-c` overrides, through the gateway, no key. if (member) return { argv: codexOverrides(profile), env }; writeCodexLayer(profile); return { argv: ["-p", profile.name], env }; } // `=` form: one argv element, never read as an option of its own. const pane = gw ? { token: paneToken, runId: runId || "pane" } : null; return { argv: [`--settings=${writeClaudeSettings(profile, member, pane)}`], env: gw ? { ANTHROPIC_AUTH_TOKEN: paneToken } : {}, }; } /** A gateway pane's own settings files of profile `name`: `[{file, runId}]`. */ function paneSettingsFiles(name) { const dir = path.dirname(claudeSettingsPath(name)); try { return fs .readdirSync(dir) .filter((f) => f.startsWith(`${name}--`) && f.endsWith(".json")) .map((f) => ({ file: path.join(dir, f), runId: f.slice(name.length + 2, -5) })); } catch { return []; // no dir yet } } /** * The pane token a pane's settings file holds, while that pane still runs on * it (`dashboard_runs`: same run, same token hash, running) — else null. */ function livePaneToken({ file, runId }) { try { const token = JSON.parse(fs.readFileSync(file, "utf8")).env?.ANTHROPIC_AUTH_TOKEN; if (typeof token !== "string" || !token) return null; const hash = require("node:crypto").createHash("sha256").update(token).digest("hex"); const live = require("../db") .db.prepare( "SELECT 1 FROM dashboard_runs WHERE id = ? AND pane_token_hash = ? AND status IN ('running', 'spawning')" ) .get(runId, hash); return live ? token : null; } catch { return null; } } /** * 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. A gateway pane's own file * (server mode) is rewritten with ITS token while the pane runs, and deleted * once it does not: its token is dead, and nothing else would remove it. */ function refreshProfileFiles(name) { const profile = proxies.getProxy(name); if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile); if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile); for (const pane of paneSettingsFiles(name)) { const token = livePaneToken(pane); if (token) writeClaudeSettings(profile, null, { token, runId: pane.runId }); else fs.rmSync(pane.file, { force: true }); } } /** * After a delete: the files hold the deleted proxy'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. The pre-rename `router-settings/` copy goes too. */ function removeProfileFiles(name) { const legacy = path.join(getDataDir(), "router-settings", `${name}.json`); // A gateway pane's own copies (`--.json`) go too. const panes = paneSettingsFiles(name).map((p) => p.file); for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy, ...panes]) { 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 proxy 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 clearLaneProxies() { 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(`[proxy] could not clear workspace ${lane.id}'s old proxy: ${e.message}`); } } } module.exports = { resolveProxy, resumeProxy, proxyForSpawn, refreshProfileFiles, removeProfileFiles, clearLaneProxies, CODEX_KEY_ENV, };