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
+36 -7
View File
@@ -80,7 +80,8 @@ function insertSource(laneId, kind, p, name, size) {
return db.prepare("SELECT * FROM lane_sources WHERE id = ?").get(info.lastInsertRowid);
}
function addFolder(laneId, input) {
/** `allowed(realPath)`: the caller's own boundary (a developer's home). */
function addFolder(laneId, input, { allowed = () => true } = {}) {
const lane = laneRow(laneId);
if (typeof input !== "string" || !input.trim()) throw fail("EBADPATH", "path is required");
let resolved;
@@ -96,6 +97,7 @@ function addFolder(laneId, input) {
} catch {
/* a vanished workspace folder still has its recorded path */
}
if (!allowed(resolved)) throw fail("EFOLDER", "an extra folder must be inside your own folder");
if (resolved === laneCwd) throw fail("EDUP", "that is the workspace's own folder");
const dup = db
.prepare("SELECT 1 FROM lane_sources WHERE lane_id = ? AND kind = 'folder' AND path = ?")
@@ -196,8 +198,23 @@ function launchExtras(laneId, tool) {
}
};
const folders = rows.filter((r) => r.kind === "folder" && isDir(r.path)).map((r) => r.path);
const files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
const addDirs = [...folders, ...(files.length ? [contextDir(laneId)] : [])];
let files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
// S3: a member's pane cannot reach the dashboard's data dir — the context
// files and the prompt are copied into the member's handoff dir instead.
const member = require("./run-as").accountForLane(laneId);
let ctx = contextDir(laneId);
if (member && files.length) {
// Written AS the member (`write-as`): the dashboard never writes in a home.
const runAs = require("./run-as");
const sub = `context-${Number(laneId)}`;
ctx = path.join(runAs.handoffDir(member), sub);
runAs.removeAs(member, ctx);
runAs.mkdirAs(member, ctx);
files = files.map((f) =>
runAs.handoffWrite(member, path.join(sub, path.basename(f)), fs.readFileSync(f))
);
}
const addDirs = [...folders, ...(files.length ? [ctx] : [])];
const parts = [];
if (lane.instructions) parts.push(lane.instructions);
if (files.length) {
@@ -216,6 +233,14 @@ function launchExtras(laneId, tool) {
// Claude's, Codex's past the inline budget), never its start — same as a
// vanished source.
try {
if (member) {
const file = require("./run-as").handoffWrite(
member,
`prompt-${Number(laneId)}.md`,
systemPrompt
);
return { addDirs, systemPrompt, systemPromptFile: file };
}
const dir = path.join(getDataDir(), "workspace-prompts");
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const systemPromptFile = path.join(dir, `${Number(laneId)}.md`);
@@ -245,11 +270,15 @@ function codexInstructions(systemPrompt, systemPromptFile) {
function readMemoryFile(label, file) {
try {
const st = fs.statSync(file);
if (!st.isFile()) return null;
const truncated = st.size > MAX_MEMORY_BYTES;
const fd = fs.openSync(file, "r");
if (!fs.statSync(file).isFile()) return null; // no blocking open on a FIFO
// S3: a file in a member's folder is read only when the OPEN file is a
// regular file that member owns — a link they planted to /etc/shadow or
// another member's login is never read with the dashboard's rights.
const fd = require("./run-as").openSafe(file);
try {
const st = fs.fstatSync(fd);
if (!st.isFile()) return null;
const truncated = st.size > MAX_MEMORY_BYTES;
const buf = Buffer.alloc(Math.min(st.size, MAX_MEMORY_BYTES));
fs.readSync(fd, buf, 0, buf.length, 0);
return {