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>
508 lines
18 KiB
JavaScript
508 lines
18 KiB
JavaScript
/**
|
|
* @file ai-proxies.js
|
|
* @description Named AI Proxy profiles — an endpoint (9Router, OpenRouter, a
|
|
* self-hosted gateway) plus the model names to use against it, saved once and
|
|
* chosen by a new session (`proxy-apply.js`).
|
|
*
|
|
* A profile is exactly what the two CLIs need to talk to a proxy 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 proxy 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.
|
|
*
|
|
* `baseUrl` is the proxy's ROOT, without `/v1`: Claude Code appends
|
|
* `/v1/messages` to it, and the Codex layer appends `/v1` itself. A `/v1`
|
|
* typed (or stored by an older build) is dropped by `normalizeBaseUrl`.
|
|
*
|
|
* Profiles are stored one JSON file per profile under
|
|
* `<CLAUDE_HOME>/agenthub-proxies/` (dir 0700, files 0600; the pre-rename
|
|
* `agenthub-routers/` is moved there on first read, see `proxiesDir`), the same
|
|
* shape and the same reasoning as `cc-profiles.js`: they hold a live API key,
|
|
* so **no route ever returns key material** — `listProxies` reports `hasKey`
|
|
* and nothing else about it.
|
|
*
|
|
* **Shared and personal.** The store's top level holds the SHARED profiles —
|
|
* the host's in local mode, the admin's in server mode. Each user may also keep
|
|
* PERSONAL ones under `users/<user id>/`, named `u<id>_<slug>`: `slugify`
|
|
* never emits `_`, so a personal name can never equal a shared one, and the
|
|
* name alone (stored in `dashboard_runs.proxy`) says whose it is. Who may see,
|
|
* write or start a session on which is decided by the routes and by
|
|
* `proxy-apply.resolveProxy`, from `ownerOfName`.
|
|
*
|
|
* `native` is a built-in profile rather than a branch in the caller: it has no
|
|
* endpoint and no model of its own (the session's model picker decides), so
|
|
* "use the vendor's own API" is a choice like any profile. It cannot be
|
|
* edited, removed, or shadowed by a saved profile.
|
|
*
|
|
* `testProxy` is the one thing here that talks to the network: the smallest
|
|
* real request each CLI would make, so "saved" and "actually works" stop
|
|
* being the same claim. It is deliberately NOT a generic forwarder — see the
|
|
* note on its stored-key fallback.
|
|
*
|
|
* @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 API. Never written anywhere (`resolveProxy` answers null for
|
|
* it), so it carries no model ids — the session's model picker picks.
|
|
*/
|
|
const NATIVE = Object.freeze({
|
|
name: BUILTIN_NAME,
|
|
label: "Native",
|
|
builtin: true,
|
|
baseUrl: "",
|
|
apiKey: "",
|
|
model: "",
|
|
models: Object.freeze({}),
|
|
});
|
|
|
|
function err(code, message) {
|
|
const e = new Error(message);
|
|
e.code = code;
|
|
return e;
|
|
}
|
|
|
|
/**
|
|
* The profile store. The first read after the rename moves the old
|
|
* `agenthub-routers/` here and leaves a symlink at the old name, so an older
|
|
* build on the same CLAUDE_HOME still finds its profiles.
|
|
*/
|
|
function proxiesDir() {
|
|
const dir = path.join(getClaudeHome(), "agenthub-proxies");
|
|
if (fs.existsSync(dir)) return dir;
|
|
const legacy = path.join(getClaudeHome(), "agenthub-routers");
|
|
try {
|
|
if (fs.lstatSync(legacy).isSymbolicLink()) return dir;
|
|
fs.renameSync(legacy, dir);
|
|
} catch {
|
|
return dir; // no legacy store (or it could not be moved — read it next time)
|
|
}
|
|
try {
|
|
fs.symlinkSync("agenthub-proxies", legacy, "dir");
|
|
} catch {
|
|
/* best effort — the profiles themselves are already in place */
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
/** A personal profile's name: `u<owner id>_<slug>`. */
|
|
const PERSONAL_RE = /^u([1-9][0-9]{0,8})_[a-z0-9][a-z0-9-]*$/;
|
|
|
|
/** The user id a personal profile belongs to, or null for a shared one. */
|
|
function ownerOfName(name) {
|
|
const m = typeof name === "string" ? PERSONAL_RE.exec(name) : null;
|
|
return m ? Number(m[1]) : null;
|
|
}
|
|
|
|
/** Where one owner's profiles live: the top level is the shared store. */
|
|
function storeDir(owner) {
|
|
return owner == null ? proxiesDir() : path.join(proxiesDir(), "users", String(owner));
|
|
}
|
|
|
|
function proxyFile(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(storeDir(ownerOfName(name)), `${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 : "proxy";
|
|
}
|
|
|
|
/**
|
|
* `base`, or `base-2`, `base-3`… — the first that is neither taken nor built
|
|
* in — prefixed `u<owner>_` for a personal profile (the slug shortened so the
|
|
* whole name still fits NAME_RE's 40 characters).
|
|
*/
|
|
function freeName(base, owner = null) {
|
|
const prefix = owner == null ? "" : `u${owner}_`;
|
|
const stem = base.slice(0, 40 - prefix.length - 4).replace(/-+$/, "") || "proxy";
|
|
let name = prefix + stem;
|
|
for (let n = 2; name === BUILTIN_NAME || fs.existsSync(proxyFile(name)); n += 1) {
|
|
name = `${prefix}${stem}-${n}`;
|
|
}
|
|
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 proxy endpoint is an http(s) URL and nothing else. A `file:` or
|
|
* `javascript:` base URL is never a proxy — it is a mistake, or an attempt to
|
|
* point a CLI somewhere it should not go. A trailing `/v1` is dropped: the
|
|
* root is stored, and each CLI's path is added where it is used.
|
|
*/
|
|
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(/\/+$/, "").replace(/\/v1$/, "");
|
|
}
|
|
|
|
/** A model name is free text — a proxy 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;
|
|
}
|
|
|
|
/**
|
|
* A known Claude model id that Claude Code should treat the proxy's own model
|
|
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
|
|
* The user picks it, never AgentHub: it also sets the context window Claude
|
|
* Code compacts at, and a proxy may route a name to a non-Claude model.
|
|
*/
|
|
function normalizeBehavesAs(value) {
|
|
const clean = typeof value === "string" ? value.trim() : null;
|
|
if (clean === "") return "";
|
|
if (!clean || !/^claude-[a-z0-9][a-z0-9.-]{0,99}$/.test(clean)) {
|
|
throw err("EBADMODEL", "behavesAs must be a Claude model id, e.g. claude-opus-5-5");
|
|
}
|
|
return clean;
|
|
}
|
|
|
|
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. A
|
|
* base URL an older build stored with `/v1` comes back as the root.
|
|
*/
|
|
function readProxy(name) {
|
|
const stored = readJson(proxyFile(name));
|
|
if (!stored || typeof stored !== "object") return null;
|
|
if (typeof stored.baseUrl === "string") {
|
|
stored.baseUrl = stored.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
}
|
|
return stored;
|
|
}
|
|
|
|
/** The browser-safe view: everything except the key, plus whether one is set. */
|
|
function publicView(profile) {
|
|
const { apiKey, ...rest } = profile;
|
|
return {
|
|
...rest,
|
|
scope: ownerOfName(profile.name) == null ? "shared" : "personal",
|
|
hasKey: Boolean(apiKey),
|
|
};
|
|
}
|
|
|
|
/** The profile names saved in one owner's store, sorted. */
|
|
function namesIn(owner) {
|
|
try {
|
|
return fs
|
|
.readdirSync(storeDir(owner))
|
|
.filter((f) => f.endsWith(".json"))
|
|
.map((f) => f.slice(0, -5))
|
|
.filter((n) => NAME_RE.test(n) && n !== BUILTIN_NAME && ownerOfName(n) === owner)
|
|
.sort();
|
|
} catch {
|
|
return []; // nothing saved yet
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The profiles `viewer` may choose: the built-in native one first, then the
|
|
* shared ones, then the viewer's own personal ones (none for `null` — local
|
|
* mode, or a request with no user). Nobody lists another user's personal
|
|
* profiles, an admin included.
|
|
* @param {number|null} [viewer] user id
|
|
* @returns `{ items }` — key material is never included.
|
|
*/
|
|
function listProxies(viewer = null) {
|
|
const items = [publicView(NATIVE)];
|
|
const owners = viewer == null ? [null] : [null, viewer];
|
|
for (const owner of owners) {
|
|
for (const name of namesIn(owner)) {
|
|
let stored;
|
|
try {
|
|
stored = readProxy(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; `listProxies` is what the browser sees.
|
|
*/
|
|
function getProxy(name) {
|
|
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
|
|
const stored = readProxy(name);
|
|
if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
|
return { ...stored, name, builtin: false };
|
|
}
|
|
|
|
/**
|
|
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
|
|
* models?:Record<string,string>, behavesAs?:string}} input
|
|
* @param {number|null} [owner] user id for a personal profile, null = shared
|
|
* @returns the browser-safe view of what was saved
|
|
*/
|
|
function createProxy(input = {}, owner = null) {
|
|
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),
|
|
behavesAs: input.behavesAs === undefined ? "" : normalizeBehavesAs(input.behavesAs),
|
|
savedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const name = freeName(slug, owner);
|
|
writeJson(proxyFile(name), profile);
|
|
return publicView({ ...profile, name, builtin: false });
|
|
}
|
|
|
|
/**
|
|
* Merge changes into a saved profile. The `name` never changes — a session's
|
|
* run row stores it for a resume, and renaming would orphan that. An omitted
|
|
* `apiKey` keeps the stored one: the edit form cannot show a key, so it cannot
|
|
* send one back either.
|
|
*/
|
|
function updateProxy(name, changes = {}) {
|
|
assertNotBuiltin(name);
|
|
const stored = readProxy(name);
|
|
if (!stored) throw err("ENOTFOUND", `no proxy 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),
|
|
behavesAs:
|
|
changes.behavesAs === undefined
|
|
? (stored.behavesAs ?? "")
|
|
: normalizeBehavesAs(changes.behavesAs),
|
|
savedAt: new Date().toISOString(),
|
|
};
|
|
|
|
writeJson(proxyFile(name), next);
|
|
return publicView({ ...next, name, builtin: false });
|
|
}
|
|
|
|
function removeProxy(name) {
|
|
assertNotBuiltin(name);
|
|
const file = proxyFile(name);
|
|
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
|
fs.rmSync(file);
|
|
return { removed: name };
|
|
}
|
|
|
|
/** How long a connection test waits before calling the endpoint dead. */
|
|
const TEST_TIMEOUT_MS = 15_000;
|
|
|
|
/** Enough of a failing proxy's own words to be actionable, not enough to flood. */
|
|
const TEST_BODY_MAX = 300;
|
|
|
|
/** POST `body` to `url`; the outcome is a result, never a throw. */
|
|
async function probe(url, apiKey, headers, body, model) {
|
|
let res;
|
|
try {
|
|
res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: `Bearer ${apiKey}`,
|
|
...headers,
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
|
// A redirect is an answer, never followed: fetch drops `authorization`
|
|
// on a cross-origin hop but would carry `x-api-key` to the new host.
|
|
redirect: "manual",
|
|
});
|
|
} catch (e) {
|
|
// Unreachable, DNS failure, TLS refusal, timeout — a real answer about the
|
|
// endpoint, so it is a result and not a 500.
|
|
//
|
|
// `cause` first: node's fetch reports every transport failure as the same
|
|
// useless "fetch failed", and hides the part the user can act on
|
|
// (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.
|
|
return {
|
|
ok: false,
|
|
status: null,
|
|
message: e.name === "TimeoutError" ? "timed out" : (e.cause && e.cause.message) || e.message,
|
|
};
|
|
}
|
|
const text = await res.text().catch(() => "");
|
|
return {
|
|
ok: res.ok,
|
|
status: res.status,
|
|
message: res.ok ? model : text.slice(0, TEST_BODY_MAX) || res.statusText,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Probe a proxy endpoint with the smallest real request each CLI makes, in
|
|
* parallel: Claude Code's Anthropic message (`/v1/messages`, one output token)
|
|
* and Codex's OpenAI Responses call (`/v1/responses`, the 16-token minimum). A
|
|
* profile is not tool-specific, so a proxy that serves only one wire shape
|
|
* shows up as one green line and one red.
|
|
*
|
|
* **The stored key is only ever sent to the endpoint it was stored against.**
|
|
* A blank `apiKey` falls back to the saved profile named by `name` — but only
|
|
* when the submitted `baseUrl` still matches the saved one. Without that check
|
|
* this route would be a way to make the server POST a saved API key to any URL
|
|
* a browser names, which is exactly the leak `listProxies` exists to prevent.
|
|
*
|
|
* A proxy that answers is a success even when it answers with an error: each
|
|
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad
|
|
* INPUT throws.
|
|
*
|
|
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
|
|
* @returns {Promise<{claude: TestResult, codex: TestResult}>} where TestResult
|
|
* is `{ok:boolean, status:number|null, message:string}`
|
|
*/
|
|
async function testProxy(input = {}) {
|
|
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
const model = normalizeModel(input.model);
|
|
|
|
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
|
|
if (!apiKey && input.name) {
|
|
const stored = readProxy(input.name);
|
|
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
|
|
}
|
|
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
|
|
|
|
const [claude, codex] = await Promise.all([
|
|
probe(
|
|
`${baseUrl}/v1/messages`,
|
|
apiKey,
|
|
// `x-api-key` beside the Bearer header, because proxies disagree on which
|
|
// one they read, and it is the same key going to the same endpoint.
|
|
{ "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
|
|
{ model, max_tokens: 1, messages: [{ role: "user", content: "ping" }] },
|
|
model
|
|
),
|
|
probe(
|
|
`${baseUrl}/v1/responses`,
|
|
apiKey,
|
|
{},
|
|
{ model, input: "ping", max_output_tokens: 16 },
|
|
model
|
|
),
|
|
]);
|
|
return { claude, codex };
|
|
}
|
|
|
|
module.exports = {
|
|
listProxies,
|
|
getProxy,
|
|
createProxy,
|
|
updateProxy,
|
|
removeProxy,
|
|
testProxy,
|
|
MODEL_SLOTS,
|
|
BUILTIN_NAME,
|
|
NATIVE,
|
|
ownerOfName,
|
|
};
|