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
+40 -8
View File
@@ -98,6 +98,38 @@ function isSecret(rel) {
return norm === SECRET_FILE || path.posix.basename(norm) === SECRET_BASENAME;
}
/**
* S3: whether `abs` is a dot entry at the TOP of a member's home — `.claude`,
* `.codex`, `.config/gh`, `.ssh`, `.git-credentials`, `.agenthub/handoff`…:
* where that member's logins and pane tokens live. A workspace is a folder
* under the home, so this never hides project files; it closes a workspace
* rooted AT the home (made before that was refused, or a symlink) to anyone
* reading it, analysts included. Not by basename: `auth`/`token` patterns
* would hide `AuthGate.tsx` in every member workspace.
*/
function inMemberDotDir(abs) {
const runAs = require("./run-as");
const acct = require("./linux-accounts").s3Enabled() ? runAs.ownerForRead(abs) : null;
if (!acct) return false;
const rel = path.relative(acct.home, abs);
return Boolean(rel) && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith(".");
}
/**
* The real path of a workspace folder for a filesystem read or write (S3:
* refused, EFOREIGN, when it resolves into another account's files). The
* account a read or write runs as comes from the PATH (run-as.js), so a
* member who swaps their workspace folder for a link to another member's
* folder, the host's home or /etc must not have the dashboard follow it.
*/
async function laneRoot(dir) {
const root = await fs.promises.realpath(dir);
if (!require("./run-as").sameOwner(path.resolve(dir), root)) {
throw badRequest("EFOREIGN", "the workspace folder resolves outside its owner's files");
}
return root;
}
/**
* Whether `file` in the lane is a secret by what it RESOLVES to, not by how
* it is spelled: a symlinked `.claude` (→ `conf/`) makes the key file
@@ -107,7 +139,7 @@ function isSecret(rel) {
* path that does not exist on disk is judged by its name alone.
*/
async function isSecretOnDisk(dir, file) {
if (isSecret(file)) return true;
if (isSecret(file) || inMemberDotDir(path.resolve(dir, file))) return true;
let root;
let real;
try {
@@ -116,7 +148,7 @@ async function isSecretOnDisk(dir, file) {
} catch {
return false;
}
if (isSecret(path.relative(root, real))) return true;
if (isSecret(path.relative(root, real)) || inMemberDotDir(real)) return true;
try {
return real === (await fs.promises.realpath(path.join(root, SECRET_FILE)));
} catch {
@@ -162,7 +194,7 @@ async function resolveInLane(dir, file) {
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
throw badRequest("EBADPATH", "path escapes the workspace directory");
}
const root = await fs.promises.realpath(dir);
const root = await laneRoot(dir);
let real;
try {
real = await fs.promises.realpath(path.resolve(root, file));
@@ -406,7 +438,7 @@ async function untrackedFacts(dir, file) {
const abs = await resolveInLane(dir, file);
if ((await fs.promises.stat(abs)).size > MAX_UNTRACKED_BYTES)
return { lines: null, binary: true };
const buf = await fs.promises.readFile(abs);
const buf = await require("./run-as").readFileSafeAsync(abs);
if (looksBinary(buf)) return { lines: null, binary: true };
const text = buf.toString("utf8");
if (!text) return { lines: 0, binary: false };
@@ -502,7 +534,7 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
async function synthesizeAddedPatch(dir, file) {
let buf;
try {
buf = await fs.promises.readFile(await resolveInLane(dir, file));
buf = await require("./run-as").readFileSafeAsync(await resolveInLane(dir, file));
} catch {
return "";
}
@@ -763,7 +795,7 @@ async function readFileAt(dir, requested, rev = "work") {
// Only cap+1 bytes: a multi-gigabyte log in the lane must not be read
// whole to answer with its first megabyte. The extra byte is what
// tells the cap below the file was longer.
const fh = await fs.promises.open(abs, "r");
const fh = await require("./run-as").openSafeAsync(abs);
try {
const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1);
buf = Buffer.alloc(size);
@@ -848,7 +880,7 @@ async function readImageAt(dir, requested, rev = "work") {
}
if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree");
if (stat.size > MAX_IMAGE_BYTES) throw tooBig();
return { type, buf: await fs.promises.readFile(abs) };
return { type, buf: await require("./run-as").readFileSafeAsync(abs) };
}
try {
const { stdout } = await git(dir, ["show", `${rev}:./${file}`], {
@@ -937,7 +969,7 @@ async function addIgnored(dir, sub, dirNames, files) {
const added = new Set();
let dirents;
try {
const root = await fs.promises.realpath(dir);
const root = await laneRoot(dir);
const real = await fs.promises.realpath(path.join(root, sub));
const r = path.relative(root, real);
// A symlinked folder pointing out of the workspace lists nothing.