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:
+35
-27
@@ -22,7 +22,6 @@
|
||||
const express = require("express");
|
||||
const { Router } = express;
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const runs = require("../lib/pty-run");
|
||||
@@ -66,22 +65,7 @@ function sanitiseCwd(input) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server mode: whether a member may work in `dir` — only inside one of their
|
||||
* own workspaces' folders, compared by real path so a symlink in their folder
|
||||
* cannot point the check elsewhere. Always true for everyone else.
|
||||
*/
|
||||
function insideOwnWorkspace(req, dir) {
|
||||
const visible = scope.visibleLaneIds(req);
|
||||
if (visible === null) return true;
|
||||
const real = lanesLib.canonicalCwd(dir);
|
||||
return visible.some((id) => {
|
||||
const cwd = lanesLib.getLane(id)?.cwd;
|
||||
if (!cwd) return false;
|
||||
const rel = path.relative(cwd, real);
|
||||
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
|
||||
});
|
||||
}
|
||||
const insideOwnWorkspace = scope.insideOwnWorkspace;
|
||||
|
||||
const refuseFolder = (res) =>
|
||||
res.status(403).json({
|
||||
@@ -128,26 +112,47 @@ const DROP_TYPES = {
|
||||
};
|
||||
const DROP_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const DROP_TTL_MS = 24 * 60 * 60_000;
|
||||
const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`);
|
||||
/** Where a drop goes: the handoff dir of the account the PANE runs as — the
|
||||
* workspace `?laneId=` names (scopeGuard already refused one this user may
|
||||
* not see), else the requester's own — written AS that member under S3, so
|
||||
* their pane can read it; else this account's data dir, never a predictable
|
||||
* name in the shared tmp, which anyone on the machine could create first and
|
||||
* then swap the dropped files in. */
|
||||
function dropDir(req) {
|
||||
const runAs = require("../lib/run-as");
|
||||
const laneId = Number(req.query?.laneId);
|
||||
const member =
|
||||
Number.isInteger(laneId) && laneId > 0 && lanesLib.getLane(laneId)
|
||||
? runAs.accountForLane(laneId)
|
||||
: runAs.accountForUser(req?.user?.username);
|
||||
return member
|
||||
? { dir: path.join(require("../lib/run-as").handoffDir(member), "drops"), member }
|
||||
: { dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null };
|
||||
}
|
||||
|
||||
// Saves one dropped body under the drop dir and answers its path. Pruned on
|
||||
// write: files older than a day go.
|
||||
function saveDrop(res, name, body) {
|
||||
function saveDrop(req, res, name, body) {
|
||||
try {
|
||||
const dir = dropDir();
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const { dir, member } = dropDir(req);
|
||||
const runAs = require("../lib/run-as");
|
||||
if (member) runAs.mkdirAs(member, dir);
|
||||
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, entry);
|
||||
try {
|
||||
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
|
||||
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
|
||||
if (member) runAs.removeAs(member, f);
|
||||
else fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
const file = path.join(dir, name);
|
||||
fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
|
||||
if (member) runAs.handoffWrite(member, path.join("drops", name), body);
|
||||
else fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
|
||||
res.json({ path: file });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -167,7 +172,7 @@ router.post(
|
||||
if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "png, jpeg, gif or webp image body required" });
|
||||
}
|
||||
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body);
|
||||
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -184,7 +189,7 @@ router.post("/drop-file", express.raw({ type: () => true, limit: DROP_MAX_BYTES
|
||||
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
}
|
||||
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
|
||||
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
|
||||
});
|
||||
|
||||
router.get("/history", (req, res) => {
|
||||
@@ -420,7 +425,10 @@ router.post("/", (req, res) => {
|
||||
proxy:
|
||||
(body.proxy ?? body.router) !== undefined
|
||||
? (body.proxy ?? body.router)
|
||||
: resumeProxy(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
|
||||
: resumeProxy(
|
||||
typeof body.resumeSessionId === "string" ? body.resumeSessionId : null,
|
||||
lane?.owner_id ?? null
|
||||
),
|
||||
model: typeof body.model === "string" && body.model ? body.model : null,
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
@@ -443,7 +451,7 @@ router.post("/", (req, res) => {
|
||||
return res.status(201).json(handle);
|
||||
} catch (err) {
|
||||
// A busy pane is a conflict with live state, not a malformed request.
|
||||
if (err.code === "ERUNLIVE") {
|
||||
if (["ERUNLIVE", "ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)) {
|
||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Nor is a host missing tmux: the request was fine, the dependency is not
|
||||
|
||||
Reference in New Issue
Block a user