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:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+59 -22
View File
@@ -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,
};