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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user