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
+112 -38
View File
@@ -57,6 +57,13 @@
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
* read, never verified).
*
* Server mode with S3 (`quotas({home})`): a member's plan is read from THEIR
* Linux account's home only — `.claude`, `.codex`, `.config/cursor` +
* `.cursor`, `.grok`, `.kimi-code`, `.local/share/opencode`, never the
* host's env overrides — through `run-as`'s owner-checked reads (F3). No
* saved Claude profiles (the Profiles modal is the host's) and no agy: its
* `/usage` needs the self-updating binary, so it stays host-only.
*
* Only percentages, window lengths, reset/expiry times, the plan's name and
* the account's email leave this module — never a token.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -74,6 +81,8 @@ const { fetchUsage, fetchOwner } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest");
const { getCursorDir } = require("./cursor-usage");
const { Database } = require("../db");
const { readFileSafe, openSafe, ownerForRead } = require("./run-as");
const { s3Enabled } = require("./linux-accounts");
const CLAUDE_TTL_MS = 5 * 60_000;
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
@@ -137,11 +146,20 @@ async function usageFor(token, uuid) {
return claudeWindows(usage);
}
/** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */
function memberClaudeAuth(home) {
const creds = readJson(path.join(home, ".claude", ".credentials.json"));
const cfg = [path.join(home, ".claude.json"), path.join(home, ".claude", ".claude.json")]
.map(readJson)
.find((c) => c?.oauthAccount);
return { oauth: creds?.claudeAiOauth || null, account: cfg?.oauthAccount || null };
}
/** The account the CLI is logged into, with the token it holds right now. */
async function liveClaude() {
async function liveClaude(readAuth = readActiveAuth) {
let who = {};
try {
const { oauth, account } = readActiveAuth();
const { oauth, account } = readAuth();
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
who = {
account: account?.emailAddress || account?.displayName || null,
@@ -204,7 +222,15 @@ function expireReset(w) {
}
/** The live account first, then every other saved profile (by label). */
async function claudeQuotas(force) {
async function claudeQuotas(force, home) {
if (home) {
const live = await cached(`${home}|live`, force, () =>
liveClaude(() => memberClaudeAuth(home))
);
if (!live) return [];
const { ownerUuid: _uuid, ...shown } = live;
return [{ id: "claude", active: true, label: null, ...shown }];
}
let items = [];
try {
items = listProfiles().items;
@@ -235,7 +261,7 @@ async function claudeQuotas(force) {
function lastRateLimits(file) {
let text;
try {
const fd = fs.openSync(file.path, "r");
const fd = openSafe(file.path);
try {
const len = Math.min(file.size, TAIL_BYTES);
const buf = Buffer.alloc(len);
@@ -282,9 +308,9 @@ function jwtClaims(token) {
}
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
function codexAccount() {
function codexAccount(codexHome = getCodexHome()) {
try {
const { tokens } = JSON.parse(fs.readFileSync(path.join(getCodexHome(), "auth.json"), "utf8"));
const { tokens } = JSON.parse(readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 }));
const { exp } = jwtClaims(tokens.access_token);
const id = jwtClaims(tokens.id_token);
return {
@@ -297,8 +323,8 @@ function codexAccount() {
}
}
function codexQuota() {
const files = allRolloutFiles()
function codexQuota(codexHome = getCodexHome()) {
const files = allRolloutFiles(codexHome)
.sort((a, b) => b.mtime - a.mtime)
.slice(0, CODEX_FILES);
for (const f of files) {
@@ -310,7 +336,7 @@ function codexQuota() {
return {
id: "codex",
active: true,
...codexAccount(),
...codexAccount(codexHome),
windows,
asOf: new Date(found.mtime).toISOString(),
};
@@ -318,18 +344,35 @@ function codexQuota() {
return null;
}
/** A JSON file, or null; a member's only if it is theirs (`readFileSafe`). */
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
return JSON.parse(readFileSafe(file, "utf8", { max: 1 << 20 }));
} catch {
return null;
}
}
/**
* Where each tool keeps its login: the host's (env overrides honoured), or a
* member's home at the tools' defaults — a member's env is not the host's.
*/
function toolDirs(home) {
const h = home || os.homedir();
const env = (k) => (home ? null : process.env[k]);
return {
codex: home ? path.join(home, ".codex") : getCodexHome(),
cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"),
cursor: home ? path.join(home, ".cursor") : getCursorDir(),
grok: env("GROK_HOME") || path.join(h, ".grok"),
kimi: env("KIMI_CODE_HOME") || path.join(h, ".kimi-code"),
opencode: path.join(env("XDG_DATA_HOME") || path.join(h, ".local", "share"), "opencode"),
};
}
/** The Cursor CLI's login and the account's plan + billing-cycle usage. */
async function cursorQuota() {
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
const token = readJson(path.join(configHome, "cursor", "auth.json"))?.accessToken;
async function cursorQuota(dirs = toolDirs()) {
const token = readJson(path.join(dirs.cursorConfig, "auth.json"))?.accessToken;
if (typeof token !== "string" || !token) return null; // not logged in
let claims = {};
try {
@@ -337,7 +380,7 @@ async function cursorQuota() {
} catch {
/* not a JWT: no expiry to check, cursor.com decides */
}
const info = readJson(path.join(getCursorDir(), "cli-config.json"))?.authInfo || {};
const info = readJson(path.join(dirs.cursor, "cli-config.json"))?.authInfo || {};
const who = {
id: "cursor",
active: true,
@@ -460,9 +503,8 @@ async function bearerWindows(who, url, token, headers, toWindows) {
}
/** Grok Build's `auth.x.ai` session and its billing view. */
async function grokQuota() {
const home = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
const sessions = readJson(path.join(home, "auth.json"));
async function grokQuota(dirs = toolDirs()) {
const sessions = readJson(path.join(dirs.grok, "auth.json"));
if (!sessions || typeof sessions !== "object") return null;
const xai = Object.entries(sessions)
.filter(([issuer, s]) => /^https:\/\/auth\.x\.ai(::|$)/.test(issuer) && s?.key)
@@ -508,9 +550,8 @@ async function grokQuota() {
}
/** Kimi Code's OAuth token and its managed-plan usage. */
async function kimiQuota() {
const home = process.env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code");
const cred = readJson(path.join(home, "credentials", "kimi-code.json"));
async function kimiQuota(dirs = toolDirs()) {
const cred = readJson(path.join(dirs.kimi, "credentials", "kimi-code.json"));
if (typeof cred?.access_token !== "string" || !cred.access_token) return null;
const exp = Number(cred.expires_at) * 1000;
const who = { id: "kimi", active: true, expiresAt: exp ? iso(exp) : null };
@@ -530,17 +571,36 @@ async function kimiQuota() {
}));
}
/**
* A member's `opencode.db`, copied (with its WAL) from owner-checked reads
* into a private temp dir — SQLite opens by NAME, which the member could swap
* between a check and the open. The host's own is opened in place.
*/
function openCodeDbPath(dir, tmp) {
const db = path.join(dir, "opencode.db");
if (!s3Enabled() || !ownerForRead(db)) return db;
const copy = path.join(tmp(), "opencode.db");
fs.writeFileSync(copy, readFileSafe(db, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
try {
fs.writeFileSync(`${copy}-wal`, readFileSafe(`${db}-wal`, null, { max: 256 << 20 }));
} catch {
/* no WAL: everything is in the main file */
}
return copy;
}
/** opencode's `opencode-go` key: `auth.json` (v1), else `opencode.db` (v2). */
function openCodeGoKey() {
const dir = path.join(
process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"),
"opencode"
);
function openCodeGoKey(dirs = toolDirs()) {
const dir = dirs.opencode;
const entry = readJson(path.join(dir, "auth.json"))?.["opencode-go"];
if (entry?.type === "api" && typeof entry.key === "string" && entry.key) return entry.key;
let conn;
let tmpDir = null;
const tmp = () => (tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-")));
try {
conn = new Database(path.join(dir, "opencode.db"), { readonly: true, fileMustExist: true });
const file = openCodeDbPath(dir, tmp);
// The private copy is writable so SQLite can replay its WAL.
conn = new Database(file, { readonly: !tmpDir, fileMustExist: true });
const rows = conn
.prepare(
"SELECT value FROM credential WHERE integration_id = 'opencode-go' ORDER BY active DESC, time_created DESC LIMIT 8"
@@ -554,12 +614,13 @@ function openCodeGoKey() {
/* no OpenCode 2 database, or no credential table (OpenCode 1) */
} finally {
conn?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
}
return null;
}
async function openCodeGoQuota() {
const key = openCodeGoKey();
async function openCodeGoQuota(dirs = toolDirs()) {
const key = openCodeGoKey(dirs);
if (!key) return null;
const who = { id: "opencode", active: true, plan: "Go" };
return bearerWindows(who, OPENCODE_GO_URL, key, {}, (body) => ({
@@ -573,18 +634,30 @@ async function openCodeGoQuota() {
}));
}
async function quotas({ force = false } = {}) {
/**
* Every tool's quota row. `home`: a member's Linux home (S3) — that account's
* logins only, and no agy; omitted, the host's.
*/
async function quotas({ force = false, home = null } = {}) {
const dirs = toolDirs(home);
const key = (k) => (home ? `${home}|${k}` : k);
const [claude, cursor, antigravity, grok, kimi, opencode] = await Promise.all([
claudeQuotas(force),
cached("cursor", force, cursorQuota),
cached("antigravity", force, antigravityQuota),
cached("grok", force, grokQuota),
cached("kimi", force, kimiQuota),
cached("opencode", force, openCodeGoQuota),
claudeQuotas(force, home),
cached(key("cursor"), force, () => cursorQuota(dirs)),
home ? null : cached("antigravity", force, antigravityQuota),
cached(key("grok"), force, () => grokQuota(dirs)),
cached(key("kimi"), force, () => kimiQuota(dirs)),
cached(key("opencode"), force, () => openCodeGoQuota(dirs)),
]);
const tools = [...claude, codexQuota(), antigravity, cursor, grok, kimi, opencode].filter(
Boolean
);
const tools = [
...claude,
codexQuota(dirs.codex),
antigravity,
cursor,
grok,
kimi,
opencode,
].filter(Boolean);
return { tools, checkedAt: new Date().toISOString() };
}
@@ -596,6 +669,7 @@ function __resetCache() {
module.exports = {
quotas,
codexWindow,
lastRateLimits,
codexAccount,
windowLabel,
agyVersionOk,