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:
+60
-7
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user