e6ce8009b1
- tool-run (CLAUDE_CODE_SHELL_PREFIX in member panes) runs each command Claude runs at oom_score_adj 1000 (session 300, Terminal tab 800, dashboard/tmux -100), so an OOM inside the capped unit kills the test run and tells Claude why, instead of killing the conversation. - member-guard.js, a synchronous managed hook for ah-* accounts: denies test runners and container commands from Claude's Bash, and refuses the prompts of a session started by hand past MAX_AI_SESSIONS. - ai-procs.js counts a member's top-level AI processes from /proc; spawnRun refuses a new pane past the limit (429 ESESSIONLIMIT). Needs `agenthub server install-system` once, then a restart.
117 lines
4.2 KiB
JavaScript
117 lines
4.2 KiB
JavaScript
/**
|
|
* @file ai-procs.js
|
|
* @description Server mode, one Linux user per account (S3): counts the AI
|
|
* Tools sessions a Linux account is running RIGHT NOW, from /proc — whoever
|
|
* started them (a dashboard pane, or `claude` typed into a terminal tab).
|
|
* A session is a process whose program is one of the catalog tools' binaries
|
|
* (`claude`, `codex`, `agy`, `cursor-agent`, `omp`) with no such process
|
|
* among its ancestors, so a nested `claude -p`, Codex's native child behind
|
|
* its npm launcher, or a subagent never counts twice. Used by `spawnRun`
|
|
* (ESESSIONLIMIT before a pane starts) and by `scripts/member-guard.js`
|
|
* (refuses the prompt of a session over the limit started by hand), which
|
|
* runs AS the member and sees only that member's processes (hidepid) — that
|
|
* is all it needs. Pure Node built-ins: the guard runs from /opt/agenthub/app.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
/** The catalog tools' binary names (server/data/ai-tools/*.json `binary`). */
|
|
const AI_BINS = new Set(["claude", "codex", "agy", "cursor-agent", "omp"]);
|
|
/** Interpreters whose first argument names the tool (an npm launcher). */
|
|
const INTERPRETERS = new Set(["node", "nodejs", "bun", "deno"]);
|
|
/** A tool's server, not a session (the host's omp share). */
|
|
const NOT_A_SESSION = new Set(["auth-gateway", "auth-broker", "mcp", "daemon"]);
|
|
|
|
const DEFAULT_LIMIT = 4;
|
|
const CONF = "/etc/agenthub/server.conf";
|
|
|
|
const base = (p) => path.basename(String(p || "")).replace(/\.(c|m)?js$/, "");
|
|
|
|
/** `{pid, ppid, uid, start, ai}` for one /proc entry, or null once it is gone. */
|
|
function readProc(pid, procRoot = "/proc") {
|
|
try {
|
|
const dir = path.join(procRoot, String(pid));
|
|
const stat = fs.readFileSync(path.join(dir, "stat"), "utf8");
|
|
// comm may hold spaces and parentheses: the fields start after the LAST ')'.
|
|
const f = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
const argv = fs.readFileSync(path.join(dir, "cmdline"), "utf8").split("\0").filter(Boolean);
|
|
const uid = fs.statSync(dir).uid;
|
|
let name = base(argv[0]);
|
|
let rest = argv.slice(1);
|
|
if (INTERPRETERS.has(name)) {
|
|
const i = rest.findIndex((a) => !a.startsWith("-"));
|
|
name = i === -1 ? "" : base(rest[i]);
|
|
rest = i === -1 ? [] : rest.slice(i + 1);
|
|
}
|
|
const ai = AI_BINS.has(name) && !rest.some((a) => NOT_A_SESSION.has(a));
|
|
return { pid: Number(pid), ppid: Number(f[1]), start: Number(f[19]), uid, ai };
|
|
} catch {
|
|
return null; // exited while we looked, or not ours to read
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The account's top-level AI sessions, oldest first.
|
|
* @param {number} uid
|
|
* @returns {{pid:number, start:number}[]}
|
|
*/
|
|
function sessionsOf(uid, procRoot = "/proc") {
|
|
const procs = new Map();
|
|
let names = [];
|
|
try {
|
|
names = fs.readdirSync(procRoot);
|
|
} catch {
|
|
return [];
|
|
}
|
|
for (const n of names) {
|
|
if (!/^\d+$/.test(n)) continue;
|
|
const p = readProc(n, procRoot);
|
|
if (p) procs.set(p.pid, p);
|
|
}
|
|
const out = [];
|
|
for (const p of procs.values()) {
|
|
if (!p.ai || p.uid !== uid) continue;
|
|
let nested = false;
|
|
const seen = new Set();
|
|
for (let a = procs.get(p.ppid); a && !seen.has(a.pid); a = procs.get(a.ppid)) {
|
|
seen.add(a.pid);
|
|
if (a.ai) {
|
|
nested = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!nested) out.push({ pid: p.pid, start: p.start });
|
|
}
|
|
return out.sort((a, b) => a.start - b.start || a.pid - b.pid);
|
|
}
|
|
|
|
/** The pid of the top-level AI session `pid` runs under (itself included), or null. */
|
|
function sessionOf(pid, procRoot = "/proc") {
|
|
let top = null;
|
|
const seen = new Set();
|
|
for (let p = readProc(pid, procRoot); p && !seen.has(p.pid); p = readProc(p.ppid, procRoot)) {
|
|
seen.add(p.pid);
|
|
if (p.ai) top = p.pid;
|
|
if (p.ppid <= 1) break;
|
|
}
|
|
return top;
|
|
}
|
|
|
|
/**
|
|
* The most AI sessions one member may run at once: `MAX_AI_SESSIONS` in
|
|
* server.conf (0 = no limit), else 4.
|
|
*/
|
|
function sessionLimit(confFile = CONF) {
|
|
try {
|
|
const m = fs.readFileSync(confFile, "utf8").match(/^MAX_AI_SESSIONS=(\d+)\s*$/m);
|
|
if (m) return Number(m[1]);
|
|
} catch {
|
|
/* no server.conf: the default */
|
|
}
|
|
return DEFAULT_LIMIT;
|
|
}
|
|
|
|
module.exports = { AI_BINS, DEFAULT_LIMIT, readProc, sessionsOf, sessionOf, sessionLimit };
|