c83ddf62a0
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>
187 lines
7.5 KiB
JavaScript
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
|
|
});
|
|
});
|