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:
+112
-38
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user