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
+68 -5
View File
@@ -275,6 +275,18 @@ function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt
function buildArgv(args, entry) {
if (entry.launcher === "codex") return buildCodexArgv(args);
if (entry.launcher === "claude") return buildClaudeArgv(args);
// S3: a member's pane cannot enter the host's home — it runs the copy every
// account shares (server/lib/shared-tools.js).
if (require("./run-as").accountForLane(args.laneId)) {
const shared = require("./shared-tools").sharedPath(entry.detect[0]);
if (!shared) {
throw makeErr(
"ENOTSHARED",
`${entry.label || entry.id} is not installed for every account yet — an admin runs \`agenthub server tools publish\``
);
}
return buildCatalogArgv(entry, shared, args);
}
const d = aiTools.detect(entry.id);
// By absolute path when found; the bare name otherwise, so a CLI missing
// from PATH dies in the pane with the shell's own "not found".
@@ -306,6 +318,14 @@ function buildArgv(args, entry) {
* name only, its key and endpoint come from the store; null/"native" = the
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
*/
/** S3: a pane's cwd must be its workspace's account's (both null outside S3). */
function assertCwdAccount(laneId, cwd) {
const runAs = require("./run-as");
if (runAs.accountForPath(cwd)?.login !== runAs.accountForLane(laneId)?.login) {
throw makeErr("ECWDACCOUNT", "that folder belongs to another account than this workspace");
}
}
function spawnRun(args) {
const {
laneId,
@@ -387,12 +407,28 @@ function spawnRun(args) {
if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
}
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
// A personal AI Proxy resolves only for its owner's workspace.
const lane = require("./lanes").getLane(laneId);
const laneOwner = lane?.owner_id ?? null;
// S3, before anything is written for this pane: it runs as its workspace
// FOLDER's account with its OWNER's token, so the two must agree (live
// role: a non-admin owns no host folder), and the cwd must be that same
// account's — else the host would run in a member's folder (their hooks,
// their .mcp.json) or a member in someone else's.
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
// A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
// own key, and an empty pool leaves the machine's login in charge.
const poolAccount =
entry.launcher === "claude" && !proxyProfile ? require("./claude-pool").pickAccount() : null;
// Codex likewise, from its own pool (codex-pool.js): an API key through
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
const codexPool =
entry.launcher === "codex" && !proxyProfile
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId))
: null;
const paneCred = require("./auth").newPaneToken();
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
@@ -430,7 +466,13 @@ function spawnRun(args) {
laneId,
tool
);
const proxySpawn = proxyForSpawn(tool, proxyProfile);
const proxySpawn = proxyForSpawn(
tool,
proxyProfile,
paneCred?.token,
require("./run-as").accountForLane(laneId),
id
);
// A new Claude session gets its id from us, so the run knows the session it
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
@@ -438,6 +480,7 @@ function spawnRun(args) {
entry.launcher === "claude" && !resumeSessionId ? require("crypto").randomUUID() : null;
const argv = buildArgv(
{
laneId,
addDirs,
systemPrompt,
systemPromptFile,
@@ -454,15 +497,20 @@ function spawnRun(args) {
resumeLast,
bypass: bypass === true,
initialPrompt,
proxyArgv: proxySpawn.argv,
proxyArgv: [...proxySpawn.argv, ...(codexPool ? codexPool.argv : [])],
},
entry
);
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
// S3: a member's pane runs as them (tmux.js starts it on their server).
const member = require("./run-as").accountForLane(laneId);
// Every launcher puts the prompt LAST; Windows has no sh to read it back.
// A member's pane cannot read the host's 0700 run-prompts dir — and needs
// no file: its whole argv already travels in its launch file, not tmux's.
const paneCmd =
initialPrompt &&
!pasteLater &&
!member &&
process.platform !== "win32" &&
Buffer.byteLength(argv[argv.length - 1]) > INLINE_PROMPT_BYTES
? promptFileArgv(id, argv)
@@ -489,7 +537,7 @@ function spawnRun(args) {
resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true,
proxy: proxyProfile ? proxyProfile.name : null,
account: poolAccount ? poolAccount.name : null,
account: (poolAccount || codexPool)?.name ?? null,
paneTokenHash: paneCred ? paneCred.hash : null,
prompt: initialPrompt || "",
status: "running",
@@ -535,11 +583,19 @@ function spawnRun(args) {
// The pool account's setup-token: outranks the machine's `/login` for
// this process only. Through tmux `-e`, never typed into the pane.
...(poolAccount ? { CLAUDE_CODE_OAUTH_TOKEN: poolAccount.token } : {}),
...(codexPool ? codexPool.env : {}),
// Server mode: the CLI, MCP server and statusline in this pane act as the
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
};
// S3: a member's pane gets their home, the shared tools and none of the
// host's desktop.
if (member) {
const own = require("./run-as").memberPaneEnv(member, env, { codex: tool === "codex" });
for (const k of Object.keys(env)) delete env[k];
Object.assign(env, own);
}
// `after-start` tools take no prompt on their command line: paste it once
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
@@ -557,6 +613,7 @@ function spawnRun(args) {
};
if (!tmux.hasSession(id)) {
codexPool?.place();
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
record();
schedulePaste();
@@ -567,6 +624,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.
codexPool?.place();
tmux.sendCommand(id, paneCmd, env, cwd);
record();
schedulePaste();
@@ -612,9 +670,14 @@ function spawnShell({ laneId, slot = 0, cwd }) {
...desktopEnv(),
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
};
const lane = require("./lanes").getLane(laneId);
if (lane) require("./run-as").assertOwnerFits(lane.cwd, lane.owner_id ?? null); // as spawnRun
assertCwdAccount(laneId, cwd); // ECWDACCOUNT, as spawnRun
const member = require("./run-as").accountForLane(laneId);
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
// the login shell tmux would have run (tmux sets SHELL in the pane).
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env });
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env: paneEnv });
dashboardRuns?.recordRun({
id,
sessionId: null,