feat(server): S3 box is for coding — OOM kills the command not the session, no tests from Claude, 4 AI sessions per member

- 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.
This commit is contained in:
2026-11-03 10:50:53 +07:00
parent 705353b0da
commit e6ce8009b1
12 changed files with 549 additions and 32 deletions
+10 -1
View File
@@ -16,6 +16,7 @@
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
# /opt/agenthub/libexec/tool-run wraps each command a member's Claude runs (OOM: it dies first)
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
# /etc/sudoers.d/agenthub checked with visudo before it is installed
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
@@ -107,6 +108,7 @@ chmod 0644 /etc/agenthub/server.conf
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
install -m 0755 -o root -g root "$ROOT/scripts/server/tool-run.sh" /opt/agenthub/libexec/tool-run
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
@@ -164,7 +166,14 @@ else
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n");
const hooks = Object.fromEntries(ev.map((e) => [e, hook(e)]));
// The guard is synchronous (it decides): no test runs or containers from
// Claude, and at most MAX_AI_SESSIONS AI sessions per member (member-guard.js).
const guard = (e) => ({ type: "command", timeout: 5,
command: `/opt/agenthub/tools/bin/node /opt/agenthub/app/scripts/member-guard.js ${e}` });
hooks.PreToolUse.push({ matcher: "Bash", hooks: [guard("PreToolUse")] });
hooks.UserPromptSubmit.push({ hooks: [guard("UserPromptSubmit")] });
process.stdout.write(JSON.stringify({ hooks }, null, 2) + "\n");
' >"$MANAGED"
chmod 0644 "$MANAGED"
sha256sum "$MANAGED" >"$MANAGED_MARK"
+17 -10
View File
@@ -11,7 +11,7 @@
#
# The file is NUL-separated: the number of environment entries, the
# NAME=value entries, then the argv (none = a login shell). Every pane starts
# at a lower CPU/IO priority and as the kernel's first OOM pick (see below).
# at a lower CPU/IO priority, Claude's commands as the kernel's first OOM pick.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -eu
f=${1:?usage: pane-launch <launch file>}
@@ -24,19 +24,26 @@ for kv in "${parts[@]:1:n}"; do
export "$kv"
done
# A member's pane shares the 4-core box with the dashboard and every other
# member: its test run, build or dev server must slow down before anything
# else does, and die first if memory runs out (11-02: 36 sessions plus test
# runs filled RAM and swap, and the box froze until a hard reset). A member
# cannot take the nice back (that needs root); oom_score_adj they can lower
# only to the unit's own -100, and even then the biggest process dies. The unit
# caps the whole cgroup below RAM (install-system.sh), so the kernel's pick
# inside it is the biggest pane process, never the dashboard or tmux.
# member, so it runs at a lower CPU/IO priority (a member cannot take the
# nice back: that needs root). Memory (11-02: 36 sessions plus test runs
# filled RAM and swap, and the box froze until a hard reset): the unit caps
# its cgroup below RAM (install-system.sh), and inside it the kernel kills
# by oom_score_adj — the dashboard and tmux at the unit's -100, an AI session
# at 300, a Terminal tab at 800, and every command Claude runs at 1000
# through CLAUDE_CODE_SHELL_PREFIX=tool-run. So a runaway command dies and
# the session that holds the conversation is told why; a member who lowers
# their own value can go no lower than -100.
renice -n 10 -p $$ >/dev/null 2>&1 || true
ionice -c 2 -n 7 -p $$ >/dev/null 2>&1 || true
echo 500 >/proc/self/oom_score_adj 2>/dev/null || true
TOOL_RUN=/opt/agenthub/libexec/tool-run
[ -x "$TOOL_RUN" ] && export CLAUDE_CODE_SHELL_PREFIX="${CLAUDE_CODE_SHELL_PREFIX:-$TOOL_RUN}"
# Vitest ≤ 3 starts one worker per core by default and these override the
# config; a member may set their own in the pane.
export VITEST_MAX_THREADS="${VITEST_MAX_THREADS:-2}" VITEST_MAX_FORKS="${VITEST_MAX_FORKS:-2}"
argv=("${parts[@]:n+1}")
if [ ${#argv[@]} -eq 0 ]; then exec -l "${SHELL:-/bin/bash}"; fi
if [ ${#argv[@]} -eq 0 ]; then
echo 800 >/proc/self/oom_score_adj 2>/dev/null || true
exec -l "${SHELL:-/bin/bash}"
fi
echo 300 >/proc/self/oom_score_adj 2>/dev/null || true
exec "${argv[@]}"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# @file tool-run.sh
# @description Server mode, one Linux user per account (S3): installed as
# /opt/agenthub/libexec/tool-run and named by CLAUDE_CODE_SHELL_PREFIX in
# every member pane (pane-launch). Claude Code runs each Bash command (and
# hook) as `tool-run '<command string>'`. The command runs at the kernel's
# highest OOM preference, so when memory runs out it is the command — a test
# run, a build — that is killed, never the Claude session above it that holds
# the conversation (pane-launch puts that one at 300). When the cgroup's OOM
# count moved while the command ran, Claude is told why it died.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
[ $# -gt 0 ] || exec /bin/bash
echo 1000 >/proc/self/oom_score_adj 2>/dev/null
cg=$(sed -n 's/^0:://p' /proc/self/cgroup 2>/dev/null)
events=/sys/fs/cgroup${cg}/memory.events
kills() { sed -n 's/^oom_kill //p' "$events" 2>/dev/null; }
before=$(kills)
/bin/bash -c "$1"
rc=$?
if [ "$rc" -ne 0 ] && [ -n "$before" ] && [ "$(kills)" != "$before" ]; then
printf '\n%s\n' "AgentHub: the server ran out of memory, so a process of this command was stopped (exit $rc). This shared server is for writing code only: do not run tests, containers or other heavy jobs here — tell the user to run them on their own machine." >&2
fi
exit "$rc"