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
+74 -7
View File
@@ -285,21 +285,36 @@ function audienceIncludes(audience, user) {
return Boolean(audience.owners && audience.owners.has(user.id));
}
/**
* A non-admin's own folder, `<AGENTHUB_USERS_ROOT>/<username>`, or null (no
* root set, or an admin). Whatever the method: an analyst reads every user's
* ROWS, but a path named in a request is read with the dashboard's own rights
* (CAP_DAC_READ_SEARCH under S3), so an analyst's GET is held to it too.
*/
function ownFolder(req) {
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
if (!req?.user || req.user.role === "admin" || !root) return null;
const path = require("node:path");
return path.join(path.resolve(root), req.user.username);
}
/**
* The folder a member's workspace may use: anywhere outside server mode or for
* an admin; for a member, only inside `<AGENTHUB_USERS_ROOT>/<username>` when
* that root is set. `dir` must already be absolute and canonical. Until
* accounts are Linux users (S3) this keeps workspaces apart, not agents: a
* terminal can still reach any folder the host account can.
* terminal can still reach any folder the host account can. Under S3 the
* home ITSELF is no workspace (its dot folders hold the member's logins and
* pane tokens, which a workspace's file views would serve) — `allowHome`
* is for browsing to a folder, not for making one a workspace.
*/
function folderAllowed(req, dir) {
if (memberId(req) == null) return true;
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
if (!root) return true;
function folderAllowed(req, dir, { allowHome = false } = {}) {
const home = ownFolder(req);
if (!home) return true;
const path = require("node:path");
const home = path.join(path.resolve(root), req.user.username);
const rel = path.relative(home, path.resolve(dir));
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
if (rel === "") return allowHome || !require("./linux-accounts").s3Enabled();
return rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel);
}
/**
@@ -322,7 +337,58 @@ function folderTakenByOther(req, dir) {
.some((r) => typeof r.cwd === "string" && (related(r.cwd, mine) || related(mine, r.cwd)));
}
/**
* Server mode: whether a non-admin may name `dir` — only inside one of their
* own workspaces' folders, compared by real path so a symlink in their folder
* cannot point the check elsewhere. An analyst's GET too (a path is read with
* the dashboard's rights, see `ownFolder`). Always true for an admin.
*/
function insideOwnWorkspace(req, dir) {
const u = req?.user;
if (!u || u.role === "admin") return true;
return insideLanes(
sql("SELECT id FROM lanes WHERE owner_id = ?")
.all(u.id)
.map((r) => r.id),
dir
);
}
/**
* Server mode: Express middleware for the config readers (`/cc-config`,
* `/codex-config`) — a non-admin's `?cwd=` (an analyst's GET included) must be
* inside one of THEIR OWN workspaces, else 403 EFOLDER. A caller-chosen
* folder is a path the dashboard reads as itself: under S3, with
* CAP_DAC_READ_SEARCH, one planted link (`/tmp/a/.claude -> /`) made it a
* whole-machine read. An admin, and local mode, pass.
*/
function ownCwdQuery(req, res, next) {
const asked = typeof req.query?.cwd === "string" && req.query.cwd;
if (!asked || !req.user || req.user.role === "admin") return next();
const own = sql("SELECT id FROM lanes WHERE owner_id = ?")
.all(req.user.id)
.map((r) => r.id);
if (require("node:path").isAbsolute(asked) && insideLanes(own, asked)) return next();
res.status(403).json({
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
});
}
function insideLanes(ids, dir) {
const path = require("node:path");
const lanesLib = require("./lanes");
const real = lanesLib.canonicalCwd(dir);
return ids.some((id) => {
const cwd = lanesLib.getLane(id)?.cwd;
if (!cwd) return false;
const rel = path.relative(cwd, real);
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
});
}
module.exports = {
insideOwnWorkspace,
ownCwdQuery,
folderTakenByOther,
scopeGuard,
broadcastAudience,
@@ -332,6 +398,7 @@ module.exports = {
canSeeRowSession,
canSeeRunRow,
folderAllowed,
ownFolder,
memberId,
ownerFor,
sessionWhere,