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:
@@ -59,7 +59,7 @@ function isUnder(root, target) {
|
||||
|
||||
function readJson(absPath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(absPath, "utf8");
|
||||
const raw = require("./run-as").readFileSafe(absPath, "utf8");
|
||||
return { ok: true, data: JSON.parse(raw), raw };
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") return { ok: false, missing: true };
|
||||
@@ -129,14 +129,14 @@ function safeReadText(absPath) {
|
||||
return {
|
||||
truncated: true,
|
||||
size: stat.size,
|
||||
text: fs.readFileSync(absPath, "utf8").slice(0, MAX_FILE_BYTES),
|
||||
text: require("./run-as").readFileSafe(absPath, "utf8").slice(0, MAX_FILE_BYTES),
|
||||
mtime: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
truncated: false,
|
||||
size: stat.size,
|
||||
text: fs.readFileSync(absPath, "utf8"),
|
||||
text: require("./run-as").readFileSafe(absPath, "utf8"),
|
||||
mtime: stat.mtimeMs,
|
||||
};
|
||||
} catch {
|
||||
@@ -837,6 +837,31 @@ function readMemory(opts = {}) {
|
||||
|
||||
// ── Single-file body endpoint (with strict path containment) ───────────
|
||||
|
||||
/**
|
||||
* A credential is never served by name, to any role: Claude's
|
||||
* `.credentials.json`, Codex's `auth.json`, any non-Markdown file named like a
|
||||
* secret, and AgentHub's own stores under CLAUDE_HOME (`agenthub-proxies`,
|
||||
* `agenthub-git-credentials`, the pool accounts…) — no route returns a key.
|
||||
*/
|
||||
function isSecretConfigPath(resolved) {
|
||||
const base = path.basename(resolved);
|
||||
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
||||
const rel = path.relative(getClaudeHome(), resolved);
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith("agenthub-"))
|
||||
return true;
|
||||
// AgentHub's data dir is no Claude config: it holds the database, the push
|
||||
// keys and every gateway pane's settings file WITH its live pane token.
|
||||
const { getDataDir, dataDirCandidates } = require("./claude-home");
|
||||
const { current, legacy } = dataDirCandidates();
|
||||
let real = resolved;
|
||||
try {
|
||||
real = fs.realpathSync(resolved);
|
||||
} catch {
|
||||
/* not there: the lexical path decides */
|
||||
}
|
||||
return [getDataDir(), current, legacy].some((d) => isUnder(d, resolved) || isUnder(d, real));
|
||||
}
|
||||
|
||||
function readFileSafe(absPath, opts = {}) {
|
||||
const allowedRoots = [
|
||||
getClaudeHome(),
|
||||
@@ -855,6 +880,7 @@ function readFileSafe(absPath, opts = {}) {
|
||||
) {
|
||||
return { error: "only CLAUDE.md is readable from project root" };
|
||||
}
|
||||
if (isSecretConfigPath(resolved)) return { error: "a credential file is never served" };
|
||||
const r = safeReadText(resolved);
|
||||
if (!r) return { error: "file not readable" };
|
||||
return { ok: true, file: resolved, ...r };
|
||||
@@ -921,6 +947,7 @@ function readOverview(opts = {}) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSecretConfigPath,
|
||||
// Exported so a test can pin it against the file the CLI writes — the two
|
||||
// drifted apart once and the mismatch was invisible from either side alone.
|
||||
getClaudeJsonPath,
|
||||
|
||||
Reference in New Issue
Block a user