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
+60 -7
View File
@@ -41,14 +41,16 @@ const ROLES = ["admin", "analyst", "developer"];
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const MIN_PASSWORD = 8;
/** API prefixes whose writes change the host account's own setup. */
/**
* API prefixes whose writes change the host account's own setup. (AI Proxy
* profiles are not here: a member writes their own personal ones, and
* `routes/ai-proxies.js` keeps the shared ones an admin's.)
*/
const ADMIN_WRITE_PREFIXES = [
"/settings",
"/updates",
"/cc-config",
"/codex-config",
"/ai-proxies",
"/ai-routers",
"/git-credentials",
"/ai-tools",
"/import",
@@ -85,6 +87,9 @@ const STAFF_ONLY_PREFIXES = [
];
// The run form's `/` autocomplete lists commands and skills.
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
// lower-cased form: the host's own AI-tool config files (they may hold keys).
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
@@ -136,13 +141,22 @@ function findUser(username) {
async function createUser(username, password, role = "developer") {
const name = validateUsername(username);
const linux = require("./linux-accounts");
linux.assertLinuxName(name);
validatePassword(password);
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
if (findUser(name)) throw err("EEXISTS", `user ${name} already exists`);
const hash = await hashPassword(password);
// S3: the Linux account first — a user who cannot get one is not created.
linux.provision("create", name);
const { lastInsertRowid } = sql(
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
).run(name, role, hash);
// S3, opt-in: the admin's plugin baseline, installed AS the new member in
// the background (each marketplace is a clone) — the user exists already.
require("./member-claude")
.applyPluginBaseline(name)
.catch((e) => console.warn(`[plugin-baseline] ${name}: ${e.message}`));
return publicUser(sql("SELECT * FROM users WHERE id = ?").get(lastInsertRowid));
}
@@ -162,21 +176,58 @@ async function setPassword(username, password) {
function setRole(username, role) {
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
sql("UPDATE users SET role = ? WHERE id = ?").run(role, requireUser(username).id);
const u = requireUser(username);
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
if (role !== "admin") stopHostPanes(u.id);
}
/**
* S3: end the live panes of `userId`'s workspaces whose folder is no
* member's — they run as the HOST account, which only an admin's may (a
* new one is refused, EOWNERFOLDER; this ends the ones already running).
*/
function stopHostPanes(userId) {
if (!require("./linux-accounts").s3Enabled()) return;
const runAs = require("./run-as");
const pty = require("./pty-run");
for (const lane of sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(userId)) {
if (runAs.ownerForRead(lane.cwd)) continue; // a member's folder: their own account
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
try {
pty.killRun(pty.runIdForLane(lane.id, slot));
} catch {
/* already gone */
}
}
}
}
/** Disable (ending every login) or re-enable a user. Users are never deleted:
* S2 will hang owned rows off their id. */
function setDisabled(username, disabled) {
const u = requireUser(username);
const linux = require("./linux-accounts");
// S3: lock (and end the processes of) the Linux account too — when there is
// one: a user made before S3, or whose name fits no Linux login, has none,
// and that must never stop an admin from revoking their dashboard access.
const hasAccount = linux.s3Enabled() && require("./run-as").hasLinuxAccount(u.username);
if (!disabled && hasAccount) linux.provision("enable", u.username);
sql("UPDATE users SET disabled = ? WHERE id = ?").run(disabled ? 1 : 0, u.id);
if (disabled) sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
// ...and their panes that run as the host (pkill below reaches only theirs).
if (disabled) stopHostPanes(u.id);
// Disable: the logins end FIRST, unconditionally; a failed lock still throws
// (EPROVISION), so the admin learns that the account's agents may still run.
if (disabled && hasAccount) linux.provision("disable", u.username);
}
/** Give a workspace (a lane id) to a user — for the ones made before server
* mode, which have no owner and so reach no member. */
function assignWorkspace(laneId, username) {
const u = requireUser(username);
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(Number(laneId));
// S3: a workspace in a member's home is theirs alone (EOWNERFOLDER).
if (lane) require("./run-as").assertOwnerFits(lane.cwd, u.id);
const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId));
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
}
@@ -338,10 +389,12 @@ function userFromPaneToken(req) {
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
if (!isLocalRequest(req)) return null;
const row = sql(
`SELECT r.id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
`SELECT r.id, r.lane_id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
).get(sha256(t));
if (!row || row.owner_id == null) return null;
// S3: only from the account that pane runs as (any local uid could post it).
if (!require("./hook-auth").peerIsPaneAccount(req, row.lane_id)) return null;
if (!require("./tmux").hasSession(row.id)) return null;
return (
publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null
@@ -396,8 +449,8 @@ function authGuard(req, res, next) {
const isRead = req.method === "GET" || req.method === "HEAD";
if (
user.role === "developer" &&
STAFF_ONLY_PREFIXES.some(under) &&
!(isRead && MEMBER_READ_ALLOW.some(under))
((STAFF_ONLY_PREFIXES.some(under) && !(isRead && MEMBER_READ_ALLOW.some(under))) ||
STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
) {
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
}