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:
@@ -142,10 +142,20 @@ function firstFileUri(buf, depth = 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transcriptFiles = (id) => {
|
||||
const dir = path.join(getAgyCliDir(), "brain", id, ".system_generated", "logs");
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
};
|
||||
/** This account's agy dir, then every member's (S3: their own home). */
|
||||
const agyDirs = () => [
|
||||
getAgyCliDir(),
|
||||
...require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".gemini", "antigravity-cli")),
|
||||
];
|
||||
|
||||
// A conversation id is a UUID, so its transcript is wherever it exists.
|
||||
const transcriptFiles = (id) =>
|
||||
agyDirs().flatMap((cli) => {
|
||||
const dir = path.join(cli, "brain", id, ".system_generated", "logs");
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
});
|
||||
|
||||
/** The transcript's last write, 0 when it has none. */
|
||||
function transcriptMtime(id) {
|
||||
@@ -164,7 +174,7 @@ function transcriptFacts(id) {
|
||||
for (const file of transcriptFiles(id)) {
|
||||
let text;
|
||||
try {
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 1024 * 1024));
|
||||
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
@@ -200,31 +210,55 @@ function transcriptFacts(id) {
|
||||
|
||||
/** Everything AgentHub reads from one conversation file. */
|
||||
function readConversation(file) {
|
||||
const conn = new Database(file, { readonly: true, fileMustExist: true });
|
||||
// S3: SQLite opens by NAME, and its unchecked `-journal`/`-wal` beside it
|
||||
// (a member's FIFO there blocks this thread forever). A member's file and
|
||||
// WAL are read through the guard into a private copy, opened writable so
|
||||
// SQLite can replay the WAL. The host's own is opened in place.
|
||||
const { ownerForRead, readFileSafe } = require("./run-as");
|
||||
let tmp = null;
|
||||
try {
|
||||
const blob = conn.prepare("SELECT data FROM trajectory_metadata_blob LIMIT 1").get();
|
||||
const usage = {};
|
||||
let model = null;
|
||||
for (const { data } of conn.prepare("SELECT data FROM gen_metadata ORDER BY idx").iterate()) {
|
||||
const u = data && generationUsage(data);
|
||||
if (!u) continue;
|
||||
model = u.model;
|
||||
const t = (usage[u.model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
for (const k of Object.keys(t)) t[k] += u[k];
|
||||
let open = file;
|
||||
if (require("./linux-accounts").s3Enabled() && ownerForRead(file)) {
|
||||
tmp = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "agenthub-agy-"));
|
||||
open = path.join(tmp, "conversation.db");
|
||||
fs.writeFileSync(open, readFileSafe(file, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
|
||||
try {
|
||||
fs.writeFileSync(`${open}-wal`, readFileSafe(`${file}-wal`, null, { max: 256 << 20 }));
|
||||
} catch {
|
||||
/* no WAL (or not theirs): what is in the main file */
|
||||
}
|
||||
}
|
||||
const conn = new Database(open, { readonly: !tmp, fileMustExist: true });
|
||||
try {
|
||||
const blob = conn.prepare("SELECT data FROM trajectory_metadata_blob LIMIT 1").get();
|
||||
const usage = {};
|
||||
let model = null;
|
||||
for (const { data } of conn.prepare("SELECT data FROM gen_metadata ORDER BY idx").iterate()) {
|
||||
const u = data && generationUsage(data);
|
||||
if (!u) continue;
|
||||
model = u.model;
|
||||
const t = (usage[u.model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
for (const k of Object.keys(t)) t[k] += u[k];
|
||||
}
|
||||
return {
|
||||
cwd: blob?.data ? firstFileUri(blob.data) : null,
|
||||
usage,
|
||||
model,
|
||||
};
|
||||
} finally {
|
||||
conn.close();
|
||||
}
|
||||
return {
|
||||
cwd: blob?.data ? firstFileUri(blob.data) : null,
|
||||
usage,
|
||||
model,
|
||||
};
|
||||
} finally {
|
||||
conn.close();
|
||||
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** The conversation files, each with the newer mtime of the file and its non-empty `-wal`. */
|
||||
function conversationFiles() {
|
||||
const dir = path.join(getAgyCliDir(), "conversations");
|
||||
return agyDirs().flatMap((cli) => conversationFilesIn(path.join(cli, "conversations")));
|
||||
}
|
||||
|
||||
function conversationFilesIn(dir) {
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(dir);
|
||||
@@ -328,6 +362,8 @@ function writeSession(f, conv, now) {
|
||||
const id = f.id;
|
||||
const existing = stmts.getSession.get(id);
|
||||
if (existing && existing.tool !== "antigravity") return null;
|
||||
// S3: a member's conversation names only their own folder and session.
|
||||
if (!require("./run-as").sameOwner(f.path, conv.cwd, existing?.cwd)) return null;
|
||||
const facts = transcriptFacts(id);
|
||||
const a = activity(id, f.mtime);
|
||||
const lastWrite = new Date(a.last).toISOString();
|
||||
@@ -484,6 +520,7 @@ module.exports = {
|
||||
pbFields,
|
||||
generationUsage,
|
||||
firstFileUri,
|
||||
readConversation,
|
||||
ingestAntigravityUsage,
|
||||
startAntigravityUsagePoll,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user