84e2a9f0ac
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
376 lines
15 KiB
JavaScript
376 lines
15 KiB
JavaScript
/**
|
|
* @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=<file>`: a settings file holding the
|
|
* profile's eight `env` keys (plus `modelPicker` rows when the profile
|
|
* sets `behavesAs`), `<data dir>/proxy-settings/<name>.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 <name>`, which layers `$CODEX_HOME/<name>.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 `<cwd>/.claude/settings.local.json`;
|
|
* `clearLaneProxies` takes that out once.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 (`<name>--<run id>.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/<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 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=<file>`, Codex's `-p <name>` 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<string,string>}}
|
|
*/
|
|
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 (`<name>--<run id>.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,
|
|
};
|