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
+81
View File
@@ -8,6 +8,12 @@
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
*
* `GET /tmux` (S3) is each member's own tmux server — its version beside the
* installed one, its sessions, whether an agent of theirs works — and an
* admin restarts one (`POST /:id/tmux-restart`) or every idle outdated one
* (`POST /tmux-restart-idle`), with their password again and audited
* (server/lib/member-tmux.js); a restart ends that member's panes.
*
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
* owner groups — staff only like the rest of `/users`.
@@ -96,6 +102,11 @@ router.get("/audit", (req, res) => {
res.json({ entries: listAudit(req.query.limit) });
});
/** S3: each member's tmux server — its version beside the installed one (member-tmux.js). */
router.get("/tmux", (_req, res) => {
res.json(require("../lib/member-tmux").overview(listUsers()));
});
const STATUS = {
EBADUSER: 400,
EBADEMAIL: 400,
@@ -114,6 +125,9 @@ const STATUS = {
ETOOMANY: 429,
// S3: the Linux account could not be created/locked (the helper said why).
EPROVISION: 502,
ENOS3: 409,
// A member's tmux server that would not stop: root has to kill it.
ETMUXSTUCK: 409,
};
/**
@@ -290,6 +304,73 @@ router.delete(
})
);
/**
* Restart one member's tmux server (every pane of theirs ends, so the next
* start runs the installed tmux) — the admin's password again, audited.
*/
router.post(
"/:id/tmux-restart",
accountChange(async (req) => {
const u = target(req);
const r = require("../lib/member-tmux").restart(u);
return {
user: auth.publicUser(u),
action: r.restarted
? `${u.username}: tmux restarted (was ${r.before.version || "?"}, ${r.before.sessions ?? "?"} session(s))`
: `${u.username}: tmux restart — no server was running`,
};
})
);
/**
* Restart the tmux server of every member running an older tmux than the
* installed one while no agent of theirs works. One password, one audit
* entry per member restarted.
*/
router.post("/tmux-restart-idle", async (req, res) => {
try {
if (!auth.isServerMode() || !req.user) {
throw Object.assign(new Error("accounts are managed in server mode only"), {
code: "ELOCALMODE",
});
}
const body = req.body && typeof req.body === "object" ? req.body : {};
await auth.confirmPassword(req.user, body.currentPassword);
const mt = require("../lib/member-tmux");
const all = db.prepare("SELECT * FROM users ORDER BY id").all();
const rows = mt.overview(all.map(auth.publicUser)).users;
const restarted = [];
const skipped = [];
const failed = [];
for (const row of rows) {
if (!row.outdated) continue;
if (row.busy > 0) {
skipped.push(row.username);
continue;
}
const u = all.find((x) => x.id === row.id);
try {
const r = mt.restart(u);
if (!r.restarted) continue;
restarted.push(row.username);
recordAccountChange(
req.user.id,
u.id,
"POST",
`${u.username}: tmux restarted (idle, was ${row.version})`
);
} catch (e) {
failed.push({ username: row.username, message: e.message });
}
}
res.json({ restarted, skipped, failed });
} catch (err) {
const status = STATUS[err.code];
if (!status) return res.status(500).json({ error: { message: err.message } });
res.status(status).json({ error: { code: err.code, message: err.message } });
}
});
router.post(
"/:id/password",
accountChange(async (req, body) => {