feat(server-s3): a member's tmux follows tmux upgrades — tmux-member + Users → Members' tmux

A member's tmux server keeps the version it started with until their last
pane closes, but the dashboard asked it with TMUX_BIN, the newest tmux: a
3.8 client on a 3.6 server spun it at 100% CPU (three members needed a
manual kill on 2026-10-15).

- /opt/agenthub/libexec/tmux-member (install-system, in sudoers): runs a
  member's tmux call with the binary of THEIR running server
  (/proc/<pid>/exe, found from the socket in /proc/net/unix), else
  TMUX_BIN. It runs as the member and execs only a process they own.
  sudoArgv uses it once installed (linux-accounts readConf), TMUX_BIN
  until then.
- Users → Members' tmux (GET /api/users/tmux): each member server's version
  beside the installed one, sessions, agents working. An admin restarts one
  (POST /api/users/:id/tmux-restart) or every idle outdated one
  (POST /api/users/tmux-restart-idle), with their password again, audited
  per member; a server that will not stop answers 409 ETMUXSTUCK naming
  its pid for root.
- server-s3-system.test.js: `delete` is a provision action since Delete
  user, so the "unknown action" case now uses `purge`.

Operators: run `sudo agenthub server install-system` once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:44:47 +07:00
parent d8ab850295
commit c83ddf62a0
20 changed files with 1066 additions and 15 deletions
+21 -1
View File
@@ -46,6 +46,13 @@ function serverConf() {
return confCache.value;
}
let TMUX_MEMBER = "/opt/agenthub/libexec/tmux-member";
/** Test seam: where tmux-member is looked for. */
function __setTmuxMember(p) {
TMUX_MEMBER = p;
confCache = { at: 0, path: null, value: null };
}
function readConf(file) {
let text;
try {
@@ -56,16 +63,28 @@ function readConf(file) {
const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || "";
const usersRoot = get("USERS_ROOT");
if (!path.isAbsolute(usersRoot)) return null;
// A member's tmux runs through tmux-member once install-system put it there
// (and in sudoers): it picks their RUNNING server's own binary, so a tmux
// upgrade never mixes a new client with an old server. Before that, TMUX_BIN.
let tmuxBin = get("TMUX_BIN");
try {
fs.accessSync(TMUX_MEMBER, fs.constants.X_OK);
if (path.isAbsolute(tmuxBin)) tmuxBin = TMUX_MEMBER;
} catch {
/* not installed yet */
}
return {
hostUser: get("HOST_USER"),
hostUid: Number(get("HOST_UID")),
usersRoot,
port: Number(get("PORT")) || null,
// The installed tmux (a new member server starts on it).
tmuxInstalled: path.isAbsolute(get("TMUX_BIN")) ? get("TMUX_BIN") : null,
// The absolute paths sudoers lists (sudo would resolve a bare name on its
// own secure_path, which may not be them).
bins: Object.fromEntries(
[
["tmux", get("TMUX_BIN")],
["tmux", tmuxBin],
["git", get("GIT_BIN")],
["gh", get("GH_BIN")],
].filter(([, v]) => path.isAbsolute(v))
@@ -154,6 +173,7 @@ function __resetConfCache() {
module.exports = {
__resetConfCache,
__setTmuxMember,
confPath,
LINUX_NAME_RE,
serverConf,