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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user