Files
AgentHub/server/__tests__/member-tmux.test.js
T
nntrivi2001 c83ddf62a0 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>
2026-10-15 14:44:47 +07:00

187 lines
7.5 KiB
JavaScript

/**
* @file Tests for each member's own tmux server on the Users page (S3,
* server/lib/member-tmux.js + `/api/users/tmux*`): its version beside the
* installed one, an admin's restart of one member (password again, audited,
* ETMUXSTUCK when it will not stop) and of every idle outdated member — a
* member whose agent is working is skipped. Member tmux calls are faked.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "member-tmux-"));
const USERS = path.join(TMP, "users");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const tmux = require("../lib/tmux");
let server;
let BASE;
const ids = {};
const BOSS_PASS = "admin-pass-1";
// Fake member tmux servers, by Linux login.
const servers = {};
function fakeMember(acct, args) {
const s = servers[acct.login];
if (!s || !s.alive) throw Object.assign(new Error("no server running"), { status: 1 });
if (args[0] === "display-message") return `${s.version}\t${s.pid}\t1700000000\t${s.sessions}\n`;
if (args[0] === "kill-server") {
if (s.stuck) throw Object.assign(new Error("server exited unexpectedly"), { status: 1 });
s.alive = false;
return "";
}
if (args[0] === "list-sessions") return "";
return "";
}
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
process.env.AGENTHUB_USERS_ROOT = USERS;
fs.mkdirSync(USERS, { recursive: true });
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.zed = (await auth.createUser("zed", "member-pass-1", "developer")).id;
ids.ann = (await auth.createUser("ann", "member-pass-1", "developer")).id;
ids.stu = (await auth.createUser("stu", "member-pass-1", "developer")).id;
// zed's agent is working right now.
db.prepare(
"INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'running')"
).run(path.join(USERS, "zed", "p"), ids.zed);
// S3 on: a server.conf whose TMUX_BIN answers 3.8, and tmux-member installed.
const bin = path.join(TMP, "tmux");
fs.writeFileSync(bin, "#!/bin/sh\necho 'tmux 3.8'\n", { mode: 0o755 });
const wrapper = path.join(TMP, "tmux-member");
fs.writeFileSync(wrapper, "#!/bin/sh\n", { mode: 0o755 });
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nTMUX_BIN=${bin}\nGIT_BIN=/usr/bin/git\n`
);
linux.__setTmuxMember(wrapper);
Object.assign(servers, {
"ah-mia": { version: "3.6", pid: 11, sessions: 2, alive: true },
"ah-zed": { version: "3.6", pid: 12, sessions: 1, alive: true },
"ah-ann": { version: "3.8", pid: 13, sessions: 3, alive: true },
"ah-stu": { version: "3.6", pid: 14, sessions: 1, alive: true, stuck: true },
});
tmux.__setAccountImpl(() => null, fakeMember);
});
after(() => {
delete process.env.AGENTHUB_MODE;
delete process.env.AGENTHUB_USERS_ROOT;
delete process.env.AGENTHUB_SERVER_CONF;
linux.__setTmuxMember("/opt/agenthub/libexec/tmux-member");
linux.__resetConfCache();
tmux.__reset();
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("member tmux servers on the Users page", () => {
it("lists each member's version beside the installed one, and who is working", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("GET", "/api/users/tmux", { cookie: boss });
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.installed, "3.8");
assert.equal(r.body.matched, true);
const by = Object.fromEntries(r.body.users.map((u) => [u.username, u]));
assert.equal(by.mia.version, "3.6");
assert.equal(by.mia.outdated, true);
assert.equal(by.mia.sessions, 2);
assert.equal(by.zed.busy, 1);
assert.equal(by.ann.outdated, false);
// boss (no server of theirs) is listed as not running.
assert.equal(by.boss.running, false);
});
it("is staff-only to read and an admin's to restart", async () => {
const mia = await login("mia", "member-pass-1");
assert.equal((await call("GET", "/api/users/tmux", { cookie: mia })).status, 403);
const r = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
cookie: mia,
body: { currentPassword: "member-pass-1" },
});
assert.equal(r.status, 403);
assert.equal(servers["ah-ann"].alive, true);
});
it("restarts one member's server with the admin's password, audited", async () => {
const boss = await login("boss", BOSS_PASS);
const wrong = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
cookie: boss,
body: { currentPassword: "nope" },
});
assert.equal(wrong.body.error.code, "EREAUTH");
assert.equal(servers["ah-ann"].alive, true);
const ok = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(ok.status, 200);
assert.equal(servers["ah-ann"].alive, false);
const audit = db
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
.get(ids.ann);
assert.match(audit.path, /ann: tmux restarted \(was 3\.8, 3 session/);
});
it("names the pid to kill as root when a server will not stop", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", `/api/users/${ids.stu}/tmux-restart`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ETMUXSTUCK");
assert.match(r.body.error.message, /sudo kill -9 14/);
});
it("restarts every idle outdated member, skipping one whose agent works", async () => {
servers["ah-ann"] = { version: "3.8", pid: 13, sessions: 1, alive: true };
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", "/api/users/tmux-restart-idle", {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(r.status, 200);
assert.deepEqual(r.body.restarted, ["mia"]);
assert.deepEqual(r.body.skipped, ["zed"]);
assert.equal(r.body.failed[0].username, "stu");
assert.equal(servers["ah-mia"].alive, false);
assert.equal(servers["ah-zed"].alive, true);
assert.equal(servers["ah-ann"].alive, true); // already on 3.8
});
});