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,
+103
View File
@@ -0,0 +1,103 @@
/**
* @file member-tmux.js
* @description Server mode, one Linux user per account (S3): each member's
* own tmux server as the Users page shows it — the version it runs next to
* the installed one (`TMUX_BIN`), how many sessions it holds and whether an
* agent of theirs is working — and an admin's restart of it. A tmux server
* keeps the version it started with until its last session closes, so after
* a tmux upgrade an old server lingers; `tmux-member` (install-system) keeps
* every call on it matched to its own binary, and this is how an admin moves
* a member onto the new version on purpose. A restart ends every pane of
* theirs (their Claude sessions resume from the workspace).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFileSync } = require("node:child_process");
const { db } = require("../db");
const linux = require("./linux-accounts");
const tmux = require("./tmux");
const err = (code, message) => Object.assign(new Error(message), { code });
// The installed tmux's version (`3.8`), re-read at most once a minute.
let installed = { at: 0, bin: null, version: null };
function installedVersion() {
const bin = linux.serverConf()?.tmuxInstalled || null;
if (installed.bin === bin && Date.now() - installed.at < 60_000) return installed.version;
let version = null;
if (bin) {
try {
version =
String(execFileSync(bin, ["-V"], { encoding: "utf8", timeout: 3000, stdio: "pipe" }))
.trim()
.replace(/^tmux\s+/, "") || null;
} catch {
/* missing or broken: unknown */
}
}
installed = { at: Date.now(), bin, version };
return version;
}
/** How many of `userId`'s workspaces have an agent working right now. */
function busyCount(userId) {
return db
.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ? AND status = 'running'")
.get(userId).n;
}
/** One user's row; null for an account with no Linux side. */
function rowFor(user, installedV) {
const acct = linux.accountFor(user.username);
if (!acct) return null;
const info = tmux.memberServerInfo(acct);
return {
id: user.id,
username: user.username,
...info,
busy: busyCount(user.id),
outdated: !!(info.running && info.version && installedV && info.version !== installedV),
};
}
/** `{available, installed, matched, users: [...]}` for the Users page. */
function overview(users) {
if (!linux.s3Enabled()) return { available: false, users: [] };
const conf = linux.serverConf();
const v = installedVersion();
return {
available: true,
installed: v,
// Whether calls on an older server already use its own binary.
matched: conf?.bins?.tmux !== conf?.tmuxInstalled,
users: users.map((u) => rowFor(u, v)).filter(Boolean),
};
}
/**
* Restart `user`'s tmux server. `{restarted: false}` when none ran; throws
* ETMUXSTUCK (with the pid) when it would not stop — root has to kill it.
*/
function restart(user) {
const acct = linux.accountFor(user.username);
if (!acct) throw err("ENOS3", "this account has no Linux user (S3 is not installed)");
const before = tmux.memberServerInfo(acct);
if (!before.running) return { restarted: false, before };
try {
tmux.killMemberServer(acct);
} catch {
/* judged by what is left below */
}
const after = tmux.memberServerInfo(acct);
if (after.running) {
const pid = before.pid || after.pid;
throw err(
"ETMUXSTUCK",
`${user.username}'s tmux server did not stop — run \`sudo kill -9 ${pid || "<pid>"}\` on the server`
);
}
return { restarted: true, before };
}
module.exports = { overview, restart, installedVersion };
+49
View File
@@ -720,6 +720,53 @@ function killSession(name) {
noteSession(name, accountOfQuiet(name), false);
}
/**
* A member's tmux server as the Users page shows it: `{running: false}`, or
* `{running: true, version, pid, startedAt, sessions}`, or `{stuck: true}`
* when it did not answer (hung, or backed off). Asked with `tmux-member`, i.e.
* with that server's own binary.
*/
function memberServerInfo(acct) {
try {
const out = String(
memberCall(acct, [
"display-message",
"-p",
"#{version}\t#{pid}\t#{start_time}\t#{server_sessions}",
])
).trim();
const [version, pid, start, sessions] = out.split("\t");
return {
running: true,
version: version || null,
pid: /^\d+$/.test(pid) ? Number(pid) : null,
startedAt: /^\d+$/.test(start) ? Number(start) * 1000 : null,
sessions: /^\d+$/.test(sessions) ? Number(sessions) : null,
};
} catch (err) {
if (err.code === "ETMUXHUNG" || err.code === "ETIMEDOUT" || err.code === "ENOBUFS") {
return { running: true, stuck: true };
}
// "no server running" / "error connecting": nothing to report.
return { running: false };
}
}
/**
* Stop a member's tmux server — every pane of theirs ends (an admin's Users
* page action, so the next start runs the installed tmux). Throws when the
* server did not go: a stuck one may need root (`sudo kill <pid>`).
*/
function killMemberServer(acct) {
unreachable.delete(acct.login); // asked deliberately, past any back-off
try {
memberCall(acct, ["kill-server"]);
} finally {
listGen++;
listed.delete(acct.login);
}
}
/** Session names starting with `prefix`. Empty array if tmux has no server running at all. */
function listSessions(prefix) {
const names = [];
@@ -867,6 +914,8 @@ module.exports = {
upgradeAdvice,
serverPid,
memberAttach,
memberServerInfo,
killMemberServer,
__setExecImpl,
__setAccountImpl,
__settle,