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:
+64
-20
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user