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
+64 -20
View File
@@ -103,6 +103,37 @@ function encodeCwd(cwd) {
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
}
/**
* The projects dir a session working in `cwd` writes its transcripts to: a
* member's own `<home>/.claude/projects` under S3 (one Linux user per account,
* server/lib/run-as.js), else this account's. A READ: a disabled or
* unprovisioned member's folder is still theirs (`ownerForRead`), never a
* throw — this runs inside async routes and the liveness reaper.
*/
function projectsDirFor(cwd) {
const acct = cwd ? require("./run-as").ownerForRead(cwd) : null;
return acct ? path.join(acct.home, ".claude", "projects") : getProjectsDir();
}
/** This account's projects dir, then every member's (S3) — for a scan by id. */
function allProjectsDirs() {
const members = require("./run-as")
.memberHomes()
.map((h) => path.join(h, ".claude", "projects"));
return [getProjectsDir(), ...members];
}
/**
* Where a by-id scan for ONE session looks: only the projects dir of the
* account that owns its folder. Under S3 another member could plant
* `<their home>/.claude/projects/x/<id>.jsonl`, a file they own, and a scan of
* every home would serve it as this session's. A session with no folder is
* this account's (`projectsDirFor(null)`), never every member's.
*/
function sessionProjectsDirs(cwd) {
return [projectsDirFor(cwd)];
}
/**
* Infer the main session JSONL file path from sessionId and cwd.
* Encoding rule: all non-alphanumeric characters replaced with "-".
@@ -111,10 +142,10 @@ function encodeCwd(cwd) {
function getTranscriptPath(sessionId, cwd) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const candidate = path.join(getProjectsDir(), encoded, `${sessionId}.jsonl`);
const candidate = path.join(projectsDirFor(cwd), encoded, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
// Fallback: scan projects/ subdirectories
return findTranscriptPath(sessionId);
// Fallback: scan the owner's projects/ subdirectories
return findTranscriptPath(sessionId, cwd);
}
/**
@@ -173,29 +204,31 @@ function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const subagentsDir = path.join(getProjectsDir(), encoded, sessionId, "subagents");
const subagentsDir = path.join(projectsDirFor(cwd), encoded, sessionId, "subagents");
const direct = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
if (direct) return direct;
// Fallback: scan all project directories
return findSubagentTranscriptPath(sessionId, agentId, runId);
// Fallback: scan the owner's project directories
return findSubagentTranscriptPath(sessionId, agentId, runId, cwd);
}
/**
* When cwd is unknown, scan projects/ subdirectories to find the JSONL file for a sessionId.
* Scan projects/ subdirectories for a sessionId's JSONL file — the owner's of
* `cwd` only (`sessionProjectsDirs`).
* Returns the found path or null.
*/
function findTranscriptPath(sessionId) {
const projectsDir = getProjectsDir();
if (!fs.existsSync(projectsDir)) return null;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
function findTranscriptPath(sessionId, cwd = null) {
for (const projectsDir of sessionProjectsDirs(cwd)) {
if (!fs.existsSync(projectsDir)) continue;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
}
} catch {
// Permission or IO error, ignore
}
} catch {
// Permission or IO error, ignore
}
return null;
}
@@ -245,8 +278,15 @@ function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) {
* - Exact: agent-<agentId>.jsonl (or workflows/<runId>/agent-<agentId>.jsonl)
* - Fuzzy: agent-acompact-*.jsonl (for compaction type)
*/
function findSubagentTranscriptPath(sessionId, agentId, runId = null) {
const projectsDir = getProjectsDir();
function findSubagentTranscriptPath(sessionId, agentId, runId = null, cwd = null) {
for (const projectsDir of sessionProjectsDirs(cwd)) {
const hit = findSubagentIn(projectsDir, sessionId, agentId, runId);
if (hit) return hit;
}
return null;
}
function findSubagentIn(projectsDir, sessionId, agentId, runId) {
if (!fs.existsSync(projectsDir)) return null;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
@@ -303,6 +343,7 @@ module.exports = {
getClaudeHome,
getProjectsDir,
getDataDir,
dataDirCandidates,
migrateLegacyDataDir,
getTranscriptSnapshotDir,
getSettingsPath,
@@ -313,5 +354,8 @@ module.exports = {
getSnapshotSubagentTranscriptPath,
findTranscriptPath,
findSubagentTranscriptPath,
projectsDirFor,
allProjectsDirs,
sessionProjectsDirs,
setClaudeHome,
};