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:
+61
-13
@@ -43,7 +43,6 @@
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const { db, stmts } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
const { getCodexHome, codexHomes } = require("./codex-home");
|
||||
@@ -166,7 +165,7 @@ function parseRolloutChunk(text, state = {}, skip = null) {
|
||||
*/
|
||||
function readChunk(file, offset, size) {
|
||||
const buf = Buffer.alloc(Math.max(0, Math.min(size - offset, MAX_CHUNK)));
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
fs.readSync(fd, buf, 0, buf.length, offset);
|
||||
} finally {
|
||||
@@ -180,8 +179,7 @@ function readChunk(file, offset, size) {
|
||||
/** Every total vector in a parent journal: the history a fork copied. Streamed. */
|
||||
async function parentTotals(file) {
|
||||
const keys = new Set();
|
||||
const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
||||
for await (const line of lines) {
|
||||
for await (const line of require("./run-as").readLines(file)) {
|
||||
if (!line.includes('"token_count"')) continue;
|
||||
try {
|
||||
const t = JSON.parse(line).payload?.info?.total_token_usage;
|
||||
@@ -214,7 +212,14 @@ const threadCache = new Map(); // rollout path → {id, sessionId}; a first line
|
||||
function threadOf(file) {
|
||||
if (typeof file !== "string" || !path.isAbsolute(file)) return null;
|
||||
const resolved = path.resolve(file);
|
||||
const under = codexHomes().some((h) => resolved.startsWith(path.join(h, "sessions") + path.sep));
|
||||
// The host's CODEX_HOME, or a member's (S3: hook-auth holds them to their own).
|
||||
const homes = [
|
||||
...codexHomes(),
|
||||
...require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".codex")),
|
||||
];
|
||||
const under = homes.some((h) => resolved.startsWith(path.join(h, "sessions") + path.sep));
|
||||
if (!under || !FILE_NAME.test(path.basename(resolved))) return null;
|
||||
if (threadCache.has(resolved)) return threadCache.get(resolved);
|
||||
const meta = firstLineMeta(resolved);
|
||||
@@ -230,7 +235,7 @@ function threadNames(home = getCodexHome()) {
|
||||
const names = new Map();
|
||||
let text = "";
|
||||
try {
|
||||
text = fs.readFileSync(path.join(home, "session_index.jsonl"), "utf8");
|
||||
text = require("./run-as").readFileSafe(path.join(home, "session_index.jsonl"), "utf8", { max: 16 << 20 });
|
||||
} catch {
|
||||
return names;
|
||||
}
|
||||
@@ -257,8 +262,13 @@ const upsertRollout = db.prepare(
|
||||
// or reached through a symlink) replaces its old row instead of counting twice.
|
||||
const dropOtherPaths = db.prepare("DELETE FROM codex_rollouts WHERE thread_id = ? AND path <> ?");
|
||||
const rolloutsOf = db.prepare(
|
||||
"SELECT thread_id, mtime_ms, state FROM codex_rollouts WHERE session_id = ?"
|
||||
"SELECT path, thread_id, mtime_ms, state FROM codex_rollouts WHERE session_id = ?"
|
||||
);
|
||||
const threadPaths = db.prepare("SELECT path FROM codex_rollouts WHERE thread_id = ? AND path <> ?");
|
||||
// S3: whose a path is ("" = the host's). A journal in a member's home speaks
|
||||
// only for that member: its session's folder must be theirs, and its ids
|
||||
// never take over another account's rows.
|
||||
const ownerKey = (p) => (p ? require("./run-as").ownerForRead(p)?.name || "" : "");
|
||||
const insertCodexSession = db.prepare(
|
||||
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'codex', ?)`
|
||||
@@ -274,6 +284,7 @@ const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
|
||||
// an event no older than the journal's newest write (less ACTIVE_MS). Hooks
|
||||
// removed, or no longer trusted, hand the status back to the journal.
|
||||
const lastEventAt = db.prepare("SELECT MAX(created_at) AS t FROM events WHERE session_id = ?");
|
||||
const anyRollout = db.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? LIMIT 1");
|
||||
const newestRollout = db.prepare(
|
||||
"SELECT MAX(mtime_ms) AS m FROM codex_rollouts WHERE session_id = ?"
|
||||
);
|
||||
@@ -292,9 +303,14 @@ const LOCAL = { source: "local", activeMs: ACTIVE_MS };
|
||||
|
||||
/** Re-derive one root session's row and usage from all its threads. */
|
||||
function writeSession(sessionId, names, now, origin = LOCAL) {
|
||||
const rows = rolloutsOf.all(sessionId).map((r) => ({ ...r, state: JSON.parse(r.state) }));
|
||||
if (rows.length === 0) return null;
|
||||
let rows = rolloutsOf.all(sessionId).map((r) => ({ ...r, state: JSON.parse(r.state) }));
|
||||
const existing = stmts.getSession.get(sessionId);
|
||||
if (rows.length && require("./linux-accounts").s3Enabled()) {
|
||||
const own = rows.find((r) => r.thread_id === sessionId) || rows[0];
|
||||
const key = existing ? ownerKey(existing.cwd) : ownerKey(own.path);
|
||||
rows = rows.filter((r) => ownerKey(r.path) === key && ownerKey(r.state.cwd) === key);
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
if (existing && existing.tool !== "codex") return null;
|
||||
// Another machine's row (or this machine's, seen from a remote pass).
|
||||
if (existing && (existing.source || "local") !== origin.source) return null;
|
||||
@@ -439,7 +455,13 @@ function ingestCodexUsage({
|
||||
offset + bytes,
|
||||
JSON.stringify(state)
|
||||
);
|
||||
if (state.threadId) dropOtherPaths.run(state.threadId, f.path);
|
||||
if (
|
||||
state.threadId &&
|
||||
threadPaths
|
||||
.all(state.threadId, f.path)
|
||||
.every((o) => ownerKey(o.path) === ownerKey(f.path))
|
||||
)
|
||||
dropOtherPaths.run(state.threadId, f.path);
|
||||
read += 1;
|
||||
if (state.sessionId) touched.add(state.sessionId);
|
||||
} catch (err) {
|
||||
@@ -478,8 +500,24 @@ function startCodexUsagePoll(broadcast) {
|
||||
? Number(process.env.DASHBOARD_CODEX_POLL_MS)
|
||||
: 15_000;
|
||||
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||
// This account's CODEX_HOME, then every member's (S3: their sessions
|
||||
// write under their own home), one after another.
|
||||
const homes = () => [
|
||||
undefined,
|
||||
...require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".codex")),
|
||||
];
|
||||
const ingestAll = async () => {
|
||||
const total = { sessions: 0 };
|
||||
for (const home of homes()) {
|
||||
const r = await ingestCodexUsage({ broadcast, home });
|
||||
total.sessions += r?.sessions || 0;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
const tick = () => {
|
||||
ingestCodexUsage({ broadcast })
|
||||
ingestAll()
|
||||
.then((r) => {
|
||||
settleActive(broadcast);
|
||||
// A pass that wrote sessions may have brought a model with no rate
|
||||
@@ -510,10 +548,20 @@ function settleActive(
|
||||
)
|
||||
.all(source, now - activeMs, now + FUTURE_SLACK_MS);
|
||||
if (quiet.length === 0) return;
|
||||
const names = threadNames(home);
|
||||
// Each session is named from its OWN home's index: S3 names a member's
|
||||
// thread in their CODEX_HOME, and the host's would rename it back to its
|
||||
// first prompt. A remote pass names its own home.
|
||||
const byHome = new Map();
|
||||
const namesOf = (id) => {
|
||||
const member =
|
||||
home === undefined ? require("./run-as").ownerForRead(anyRollout.get(id)?.path || "") : null;
|
||||
const h = member ? path.join(member.home, ".codex") : home;
|
||||
if (!byHome.has(h)) byHome.set(h, threadNames(h));
|
||||
return byHome.get(h);
|
||||
};
|
||||
for (const { id } of quiet) {
|
||||
if (hooksCurrent(id, newestRollout.get(id)?.m || 0)) continue;
|
||||
if (writeSession(id, names, now, { source, activeMs }))
|
||||
if (writeSession(id, namesOf(id), now, { source, activeMs }))
|
||||
broadcast("session_updated", stmts.getSession.get(id));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user