feat(server-s3): one Linux user per account (S3) — experimental

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>
This commit is contained in:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+140 -25
View File
@@ -27,6 +27,12 @@
* 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.
@@ -41,6 +47,10 @@ 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
@@ -49,11 +59,16 @@ const { PROXY_CAPABLE } = require("./ai-tools");
*/
const CODEX_KEY_ENV = "AGENTHUB_PROXY_KEY";
/** The eight env keys a proxy sets for Claude Code. */
/**
* 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: profile.baseUrl,
ANTHROPIC_AUTH_TOKEN: profile.apiKey,
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,
@@ -94,12 +109,29 @@ function modelPicker(profile) {
return options.length ? { modelPicker: { options } } : {};
}
function writeClaudeSettings(profile) {
const file = claudeSettingsPath(profile.name);
/**
* 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)).filter(([, v]) => v));
writeSecret(file, `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`);
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;
}
@@ -114,6 +146,7 @@ function tomlString(value) {
*/
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` +
@@ -121,11 +154,11 @@ function writeCodexLayer(profile) {
`model_provider = ${tomlString(profile.name)}\n\n` +
`[model_providers.${profile.name}]\n` +
`name = ${tomlString(profile.label)}\n` +
`base_url = ${tomlString(`${profile.baseUrl}/v1`)}\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.
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
(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`;
@@ -133,19 +166,40 @@ function writeCodexLayer(profile) {
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. `null`,
* `""` and `"native"` mean the vendor's own API and resolve to null.
* @throws EBADPROXY for a profile the store does not have, EUNSUPPORTED for a
* proxy on a tool that has no endpoint to point at one.
* 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) {
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;
@@ -164,12 +218,15 @@ function resolveProxy(tool, name) {
/**
* The proxy a resume that names none goes back to: the one its session last
* ran on, while that profile still exists — a deleted one resumes on native.
* 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) {
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;
@@ -180,31 +237,87 @@ function resumeProxy(sessionId) {
/**
* Argv and env one spawn needs for `profile` (from `resolveProxy`): Claude's
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
* `--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) {
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: profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {},
};
return { argv: ["-p", profile.name], env };
}
// `=` form: one argv element, never read as an option of its own.
return { argv: [`--settings=${writeClaudeSettings(profile)}`], env: {} };
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.
* 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 });
}
}
/**
@@ -214,7 +327,9 @@ function refreshProfileFiles(name) {
*/
function removeProfileFiles(name) {
const legacy = path.join(getDataDir(), "router-settings", `${name}.json`);
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy]) {
// 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 {