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
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,267 @@
/**
* @file MemberTmuxCard.tsx
* @description The Users page's tmux card (one Linux user per account only,
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
* beside the installed one, its sessions, whether an agent of theirs is
* working — so an admin sees who still runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
? t(`tmux.errors.${err.code}`)
: err.message;
}
const BADGE = "badge ml-1.5 py-0 text-[10px]";
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users");
const [data, setData] = useState<MemberTmux | null>(null);
const [error, setError] = useState<string | null>(null);
// A member row = restart that one; "idle" = every idle outdated member.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const load = useCallback(
() =>
api.users
.tmux()
.then((r) => {
setData(r);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
}, [load]);
if (!data?.available) return null;
const rows = data.users.filter((u) => u.running);
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<section className="card" data-testid="member-tmux">
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
<TerminalSquare className="w-4 h-4 text-fg-muted" />
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
<p className="text-xs text-fg-muted">
{t("tmux.description", { version: data.installed ?? "?" })}
</p>
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
title={t("tmux.refresh")}
aria-label={t("tmux.refresh")}
className="btn-icon"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
{isAdmin && idleOutdated.length > 0 && (
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
<RotateCcw className="w-4 h-4" />{" "}
{t("tmux.restartIdle", { count: idleOutdated.length })}
</button>
)}
</div>
{!data.matched && (
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
{t("tmux.notMatched")}
</p>
)}
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{rows.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px]">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("tmux.user")}</th>
<th className={th}>{t("tmux.version")}</th>
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
<th className={th}>{t("tmux.agents")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((u) => (
<tr key={u.id}>
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
{u.startedAt && (
<div className="text-[11px] text-fg-muted">
{t("tmux.since", {
at: formatDateTime(new Date(u.startedAt).toISOString()),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions ?? "—"}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
</td>
{isAdmin && (
<td className="px-5 py-2 text-right">
<button
type="button"
onClick={() => setRestarting(u)}
title={t("tmux.restart", { name: u.username })}
aria-label={t("tmux.restart", { name: u.username })}
className="btn-icon"
>
<RotateCcw className="h-4 w-4" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{restarting && (
<RestartDialog
target={restarting}
idle={idleOutdated}
onClose={() => setRestarting(null)}
onDone={(msg) => {
toast.success(msg);
setRestarting(null);
void load();
}}
/>
)}
</section>
);
}
function RestartDialog({
target,
idle,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
idle: MemberTmuxRow[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
}
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
message={
one
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
name: one.username,
count: one.sessions ?? 0,
})
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
}
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
@@ -0,0 +1,100 @@
/**
* @file MemberTmuxCard.test.tsx
* @description The Users page's members' tmux card: hidden without one Linux
* user per account, lists each running member server with its version and a
* badge when it is older than the installed tmux, warns until tmux-member is
* installed, and an admin restarts one member — or every idle outdated one,
* the working member left out — only with their own password typed; an
* analyst sees no restart control.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { MemberTmux } from "../../../lib/api";
const users = vi.hoisted(() => ({
tmux: vi.fn(),
tmuxRestart: vi.fn(),
tmuxRestartIdle: vi.fn(),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { users } };
});
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { MemberTmuxCard } from "../MemberTmuxCard";
const DATA: MemberTmux = {
available: true,
installed: "3.8",
matched: true,
users: [
{ id: 2, username: "mia", running: true, version: "3.6", sessions: 2, busy: 0, outdated: true },
{ id: 3, username: "zed", running: true, version: "3.6", sessions: 1, busy: 1, outdated: true },
{
id: 4,
username: "ann",
running: true,
version: "3.8",
sessions: 3,
busy: 0,
outdated: false,
},
{ id: 5, username: "bob", running: false, busy: 0, outdated: false },
],
};
beforeEach(() => {
vi.clearAllMocks();
users.tmux.mockResolvedValue(DATA);
});
describe("MemberTmuxCard", () => {
it("renders nothing without one Linux user per account", async () => {
users.tmux.mockResolvedValue({ available: false, users: [] });
const { container } = render(<MemberTmuxCard isAdmin />);
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("lists running servers and marks the ones older than the installed tmux", async () => {
render(<MemberTmuxCard isAdmin={false} />);
expect(await screen.findByText("mia")).toBeInTheDocument();
expect(screen.queryByText("bob")).toBeNull();
expect(screen.getAllByText("older than installed")).toHaveLength(2);
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
// An analyst reads only.
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
});
it("warns until tmux-member is installed", async () => {
users.tmux.mockResolvedValue({ ...DATA, matched: false });
render(<MemberTmuxCard isAdmin />);
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
});
it("restarts one member only with the admin's password", async () => {
users.tmuxRestart.mockResolvedValue({});
render(<MemberTmuxCard isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
const go = screen.getByRole("button", { name: "Restart" });
expect(go).toBeDisabled();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
await userEvent.click(go);
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2, "pw"));
});
it("restarts every idle outdated member at once, the working one left out", async () => {
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
render(<MemberTmuxCard isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith("pw"));
});
});
+30
View File
@@ -204,5 +204,35 @@
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.", "body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
"confirm": "Delete for good", "confirm": "Delete for good",
"needsPassword": "Enter your own password below first." "needsPassword": "Enter your own password below first."
},
"tmux": {
"title": "Members' tmux",
"description": "Each member's own tmux server keeps the version it started with until its last pane closes. Installed: tmux {{version}}.",
"refresh": "Refresh",
"notMatched": "Run `sudo agenthub server install-system` once: until then an older member server is asked with the installed tmux, which can leave it stuck at 100% CPU.",
"empty": "No member has a tmux server running.",
"user": "User",
"version": "tmux",
"sessions": "Sessions",
"agents": "Agents",
"stuck": "not answering",
"outdated": "older than installed",
"since": "since {{at}}",
"working_one": "{{count}} working",
"working_other": "{{count}} working",
"idle": "idle",
"restart": "Restart {{name}}'s tmux",
"restartIdle_one": "Restart {{count}} idle member",
"restartIdle_other": "Restart {{count}} idle members",
"restartIdleTitle": "Restart idle members' tmux",
"restartHint": "Every pane of {{name}} closes ({{count}} sessions). Their Claude sessions resume from the workspace, and the next start runs the installed tmux.",
"restartHintBusy": "An agent of {{name}} is working right now and will be stopped. Every pane of theirs closes ({{count}} sessions); their Claude sessions resume from the workspace.",
"restartIdleHint": "Restarts the tmux of every member on an older version with no agent working: {{names}}. Their panes close; their Claude sessions resume from the workspace.",
"restartButton": "Restart",
"done": "{{name}}'s tmux restarted",
"doneIdle": "Restarted: {{names}}",
"errors": {
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
}
} }
} }
+30
View File
@@ -204,5 +204,35 @@
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.", "body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
"confirm": "Xoá vĩnh viễn", "confirm": "Xoá vĩnh viễn",
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước." "needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
},
"tmux": {
"title": "Tmux của member",
"description": "Mỗi member có tmux server riêng, giữ phiên bản lúc nó khởi động cho đến khi pane cuối cùng đóng. Bản đã cài: tmux {{version}}.",
"refresh": "Làm mới",
"notMatched": "Chạy `sudo agenthub server install-system` một lần: trước đó, server cũ của member được hỏi bằng tmux bản mới và có thể kẹt ở 100% CPU.",
"empty": "Không member nào đang chạy tmux server.",
"user": "Người dùng",
"version": "tmux",
"sessions": "Session",
"agents": "Agent",
"stuck": "không trả lời",
"outdated": "cũ hơn bản cài",
"since": "từ {{at}}",
"working_one": "{{count}} đang chạy",
"working_other": "{{count}} đang chạy",
"idle": "rảnh",
"restart": "Khởi động lại tmux của {{name}}",
"restartIdle_one": "Khởi động lại {{count}} member đang rảnh",
"restartIdle_other": "Khởi động lại {{count}} member đang rảnh",
"restartIdleTitle": "Khởi động lại tmux của member đang rảnh",
"restartHint": "Mọi pane của {{name}} sẽ đóng ({{count}} session). Phiên Claude của họ resume lại được từ workspace, và lần Start sau sẽ chạy tmux bản đã cài.",
"restartHintBusy": "Agent của {{name}} đang chạy và sẽ bị dừng. Mọi pane của họ sẽ đóng ({{count}} session); phiên Claude resume lại được từ workspace.",
"restartIdleHint": "Khởi động lại tmux của mọi member đang chạy bản cũ mà không có agent nào đang chạy: {{names}}. Pane của họ sẽ đóng; phiên Claude resume lại được từ workspace.",
"restartButton": "Khởi động lại",
"done": "Đã khởi động lại tmux của {{name}}",
"doneIdle": "Đã khởi động lại: {{names}}",
"errors": {
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
}
} }
} }
+43
View File
@@ -544,6 +544,31 @@ export type ServerUserUsage = AuthUser & {
logins?: number; logins?: number;
lastLoginAt?: string | null; lastLoginAt?: string | null;
}; };
/** One member's own tmux server (`GET /api/users/tmux`). */
export interface MemberTmuxRow {
id: number;
username: string;
running: boolean;
/** Did not answer (hung, or backed off). */
stuck?: boolean;
version?: string | null;
pid?: number | null;
startedAt?: number | null;
sessions?: number | null;
/** Workspaces of theirs with an agent working now. */
busy: number;
/** Runs an older tmux than the installed one. */
outdated: boolean;
}
/** `GET /api/users/tmux`: `available` only with one Linux user per account. */
export interface MemberTmux {
available: boolean;
installed?: string | null;
/** Calls on an older server already use its own binary (tmux-member installed). */
matched?: boolean;
users: MemberTmuxRow[];
}
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */ /** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
export type UserStorage = { export type UserStorage = {
bytes: number; bytes: number;
@@ -915,6 +940,24 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ ...body, currentPassword }), body: JSON.stringify({ ...body, currentPassword }),
}), }),
/** GET /api/users/tmux — each member's own tmux server beside the installed version (S3). */
tmux: () => request<MemberTmux>("/users/tmux"),
/** POST /api/users/:id/tmux-restart — end that member's tmux server (every pane of theirs). */
tmuxRestart: (id: number, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/tmux-restart`, {
method: "POST",
body: JSON.stringify({ currentPassword }),
}),
/** POST /api/users/tmux-restart-idle — restart every outdated member server with no agent working. */
tmuxRestartIdle: (currentPassword: string) =>
request<{
restarted: string[];
skipped: string[];
failed: { username: string; message: string }[];
}>("/users/tmux-restart-idle", {
method: "POST",
body: JSON.stringify({ currentPassword }),
}),
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */ /** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
get: (id: number) => request<UserDetail>(`/users/${id}`), get: (id: number) => request<UserDetail>(`/users/${id}`),
/** PATCH /api/users/:id — profile, role, state, password, workspaces and /** PATCH /api/users/:id — profile, role, state, password, workspaces and
+5 -1
View File
@@ -9,7 +9,9 @@
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile, * an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent * role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run * as one change), and the Claude/Codex account pool every user's sessions run
* on (`AccountPoolCard`). Every change asks for the admin's OWN password again (the * on (`AccountPoolCard`), and (one Linux user per account) each member's own
* tmux server beside the installed version, restartable by an admin
* (`MemberTmuxCard`). Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display * server refuses without it); an analyst only reads. A row shows the display
* name and email under the username when set. * name and email under the username when set.
* Laid out like the other list pages: icon header, a row of totals * Laid out like the other list pages: icon header, a row of totals
@@ -48,6 +50,7 @@ import { ConfirmModal } from "../components/ConfirmModal";
import { StatCard } from "../components/StatCard"; import { StatCard } from "../components/StatCard";
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog"; import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard"; import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { MemberTmuxCard } from "../components/users/MemberTmuxCard";
import { TableRowSkeleton } from "../components/Skeleton"; import { TableRowSkeleton } from "../components/Skeleton";
type Role = AuthUser["role"]; type Role = AuthUser["role"];
@@ -296,6 +299,7 @@ export function Users() {
</div> </div>
<AccountPoolCard isAdmin={isAdmin} /> <AccountPoolCard isAdmin={isAdmin} />
<MemberTmuxCard isAdmin={isAdmin} />
<section className="card"> <section className="card">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border"> <div className="flex items-center gap-2 px-5 py-4 border-b border-border">
@@ -40,6 +40,8 @@ const users = vi.hoisted(() => ({
setPassword: vi.fn(), setPassword: vi.fn(),
assignWorkspace: vi.fn(), assignWorkspace: vi.fn(),
get: vi.fn(), get: vi.fn(),
// No S3 here: the members' tmux card stays hidden.
tmux: vi.fn(async () => ({ available: false, users: [] })),
})); }));
vi.mock("../../lib/api", async (importOriginal) => { vi.mock("../../lib/api", async (importOriginal) => {
+1 -1
View File
@@ -85,7 +85,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode | | `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared | | `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
+22 -8
View File
@@ -544,9 +544,9 @@ The pieces, and what each is for (the install steps are in the
| `ah-<name>` | Made by `agenthub-provision`, not by the installer. One Linux user per AgentHub user, home `/srv/agenthub/users/<name>` (`0750`, group `agenthub`): the member owns it, the dashboard reads it, nobody else gets in. `.agenthub/{tmux,handoff}` (0700) is made by the member: their tmux socket, and the files the dashboard writes for their panes (as them) | | `ah-<name>` | Made by `agenthub-provision`, not by the installer. One Linux user per AgentHub user, home `/srv/agenthub/users/<name>` (`0750`, group `agenthub`): the member owns it, the dashboard reads it, nobody else gets in. `.agenthub/{tmux,handoff}` (0700) is made by the member: their tmux socket, and the files the dashboard writes for their panes (as them) |
| groups | `agenthub` (reads member homes: the host account), `agenthub-members` (every `ah-*` account), `agenthub-proc` (sees every process: the host account, `polkitd`) | | groups | `agenthub` (reads member homes: the host account), `agenthub-members` (every `ah-*` account), `agenthub-proc` (sees every process: the host account, `polkitd`) |
| `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root | | `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root |
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers | | `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/tmux-member`, `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
| `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on | | `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on |
| `write-as`, `pane-launch` | `/opt/agenthub/libexec/`, root's. `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file | | `write-as`, `pane-launch`, `tmux-member` | `/opt/agenthub/libexec/`, root's. `tmux-member -S <socket> …` runs a member's tmux call with their running server's own binary (else `TMUX_BIN`). `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
| `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything | | `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything |
| `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone | | `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone |
| `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one | | `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one |
@@ -682,9 +682,22 @@ a pane really is. Each case below is refused or bounded, with a test in
running host server uses that server's own binary (`/proc/<pid>/exe`, found running host server uses that server's own binary (`/proc/<pid>/exe`, found
from its socket in `/proc`), so installing a newer tmux under a running from its socket in `/proc`), so installing a newer tmux under a running
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
100% CPU. A member's server is still asked with `TMUX_BIN` (the one path 100% CPU.
sudoers allows), so after a tmux upgrade restart each member's tmux server - **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
once it is idle (`sudo kill <pid>` of `tmux -S /srv/agenthub/users/<name>/.agenthub/tmux/sock`). through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
the call with the binary of the member's running server (`/proc/<pid>/exe`,
found from their socket), else with `TMUX_BIN`, so a newer tmux never asks
an older server. Until `sudo agenthub server install-system` has installed
it, calls use `TMUX_BIN` directly, as before. A member's server keeps its
version until their last pane closes; the next start runs `TMUX_BIN`.
- **Users → Members' tmux** (admin/analyst read, admin acts): each member's
running server with its version beside the installed one (an *older than
installed* badge), its sessions and whether an agent of theirs is working.
**Restart** ends one member's server — every pane of theirs closes, their
Claude sessions resume from the workspace — and **Restart N idle members**
does it for every outdated member with no agent working; both take the
admin's password again and are audited per member. A server that will not
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
- **A member's huge or sparse files.** Transcripts and journals are read line - **A member's huge or sparse files.** Transcripts and journals are read line
by line with a 64 MiB cap per line (a 512 MB line used to crash the server); by line with a 64 MiB cap per line (a 512 MB line used to crash the server);
a whole member file is read only up to a cap (1 MiB for a login or a a whole member file is read only up to a cap (1 MiB for a login or a
@@ -746,10 +759,11 @@ itself, once.
### After an update that changes the member helpers ### After an update that changes the member helpers
`write-as`, `pane-launch` and the sudoers file are copied to `write-as`, `pane-launch`, `tmux-member` and the sudoers file are copied to
`/opt/agenthub/libexec` by `install-system` only — an update of AgentHub does `/opt/agenthub/libexec` by `install-system` only — an update of AgentHub does
not touch root-owned files. When a release changes them (v2.7.7: `write-as` not touch root-owned files. When a release changes them (v2.7.7: `write-as`
learned `move`/`copy` for the File Explorer), re-run it once: learned `move`/`copy` for the File Explorer; v2.8.4 added `tmux-member`),
re-run it once:
```bash ```bash
sudo agenthub server install-system sudo agenthub server install-system
@@ -821,7 +835,7 @@ again changes nothing that is already in place):
host account's); host account's);
3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`, 3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`,
`USERS_ROOT`, `PORT`, `TMUX_BIN`, `GIT_BIN`, `GH_BIN`); `USERS_ROOT`, `PORT`, `TMUX_BIN`, `GIT_BIN`, `GH_BIN`);
4. installs `agenthub-provision`, `write-as` and `pane-launch` from 4. installs `agenthub-provision`, `write-as`, `pane-launch` and `tmux-member` from
`scripts/server/`; `scripts/server/`;
5. copies node to `/opt/agenthub/tools/node/<version>` (linked from 5. copies node to `/opt/agenthub/tools/node/<version>` (linked from
`tools/bin/node`) and this install's `scripts/` and `server/lib/` to `tools/bin/node`) and this install's `scripts/` and `server/lib/` to
+3 -1
View File
@@ -16,6 +16,7 @@
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members # /opt/agenthub/libexec/member-hook Claude hook forwarder for members
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u) # /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file # /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
# /etc/sudoers.d/agenthub checked with visudo before it is installed # /etc/sudoers.d/agenthub checked with visudo before it is installed
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file) # /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab) # /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
@@ -105,6 +106,7 @@ chmod 0644 /etc/agenthub/server.conf
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ── # ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
NODE_VER=$("$NODE" -p process.version) NODE_VER=$("$NODE" -p process.version)
@@ -138,7 +140,7 @@ visudo -cf "$TMP_SUDOERS" >/dev/null 2>&1 && NOTTY="Defaults:$HOST !requiretty"
{ {
echo "# AgentHub server mode (install-system). Members are never in sudoers." echo "# AgentHub server mode (install-system). Members are never in sudoers."
[ -z "$NOTTY" ] || echo "$NOTTY" [ -z "$NOTTY" ] || echo "$NOTTY"
echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude" echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, /opt/agenthub/libexec/tmux-member, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude"
echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision" echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision"
for a in start stop restart enable disable; do for a in start stop restart enable disable; do
[ "$a" = start ] || [ "$a" = stop ] || [ "$a" = restart ] && extra=" --no-block" || extra="" [ "$a" = start ] || [ "$a" = stop ] || [ "$a" = restart ] && extra=" --no-block" || extra=""
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# @file tmux-member.sh
# @description Server mode, one Linux user per account (S3): installed as
# /opt/agenthub/libexec/tmux-member, the one tmux the host may run AS a
# member (sudoers). Called as `tmux-member -S <socket> <tmux args…>`, it runs
# the args with the binary of the tmux server already listening on that
# socket (`/proc/<pid>/exe`, which still runs after an upgrade replaced the
# file), else with TMUX_BIN from /etc/agenthub/server.conf (no server yet:
# this call starts one, on the installed version). A client must match its
# server: a tmux 3.8 client asking a 3.6 server kept it at 100% CPU.
#
# It runs as the member and execs only a process THEY own (under hidepid they
# see no other), so it can run nothing they could not run themselves.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -u
bin=$(sed -n 's/^TMUX_BIN=//p' /etc/agenthub/server.conf 2>/dev/null | head -n 1)
[[ "$bin" == /* ]] || { echo "tmux-member: no TMUX_BIN in /etc/agenthub/server.conf" >&2; exit 2; }
if [ "${1:-}" = -S ] && [ -n "${2:-}" ]; then
# /proc/net/unix: Num RefCount Protocol Flags Type St Inode Path; 00010000 = listening.
inode=$(awk -v s="$2" '$4 == "00010000" && $8 == s { print $7; exit }' /proc/net/unix 2>/dev/null)
if [ -n "$inode" ]; then
for d in /proc/[0-9]*; do
[ -O "$d" ] || continue
read -r comm <"$d/comm" 2>/dev/null || continue
[[ "$comm" == tmux* ]] || continue
for fd in "$d"/fd/*; do
if [ "$(readlink "$fd" 2>/dev/null)" = "socket:[$inode]" ] && [ -x "$d/exe" ]; then
exec "$d/exe" "$@"
fi
done
done
fi
fi
exec "$bin" "$@"
+186
View File
@@ -0,0 +1,186 @@
/**
* @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
});
});
+12 -1
View File
@@ -240,9 +240,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
process.env.AGENTHUB_SERVER_CONF, process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nGIT_BIN=/usr/bin/git\nTMUX_BIN=/opt/x/tmux\n` `HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nGIT_BIN=/usr/bin/git\nTMUX_BIN=/opt/x/tmux\n`
); );
linux.__resetConfCache(); linux.__setTmuxMember(path.join(TMP, "no-tmux-member"));
assert.equal(runAs.sudoArgv(ACCT, "git", ["status"])[1][3], "/usr/bin/git"); assert.equal(runAs.sudoArgv(ACCT, "git", ["status"])[1][3], "/usr/bin/git");
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], "/opt/x/tmux"); assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], "/opt/x/tmux");
// Once install-system put tmux-member there, a member's tmux goes through
// it (their running server's own binary); the installed one is still known.
const wrapper = path.join(TMP, "tmux-member");
fs.writeFileSync(wrapper, "#!/bin/sh\n", { mode: 0o755 });
linux.__setTmuxMember(wrapper);
try {
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], wrapper);
assert.equal(linux.serverConf().tmuxInstalled, "/opt/x/tmux");
} finally {
linux.__setTmuxMember(path.join(TMP, "no-tmux-member"));
}
assert.equal( assert.equal(
runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3], runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3],
"/opt/agenthub/libexec/write-as" "/opt/agenthub/libexec/write-as"
+3 -1
View File
@@ -187,6 +187,7 @@ describe("Linux accounts", () => {
hostUid: 4242, hostUid: 4242,
usersRoot: "/srv/agenthub/users", usersRoot: "/srv/agenthub/users",
port: 4820, port: 4820,
tmuxInstalled: null,
bins: {}, bins: {},
}); });
assert.deepEqual(linux.accountFor("mia"), { assert.deepEqual(linux.accountFor("mia"), {
@@ -199,7 +200,8 @@ describe("Linux accounts", () => {
for (const bad of ["Mia", "1mia", "mi.a", "-x", "a".repeat(30), "root;id", "../x"]) { for (const bad of ["Mia", "1mia", "mi.a", "-x", "a".repeat(30), "root;id", "../x"]) {
assert.throws(() => linux.provision("create", bad), { code: "EBADUSER" }, bad); assert.throws(() => linux.provision("create", bad), { code: "EBADUSER" }, bad);
} }
assert.throws(() => linux.provision("delete", "mia"), /unknown provision action/); // `delete` is an action now (Delete user); anything else is still refused.
assert.throws(() => linux.provision("purge", "mia"), /unknown provision action/);
assert.deepEqual(argvs, []); assert.deepEqual(argvs, []);
}); });
+21 -1
View File
@@ -46,6 +46,13 @@ function serverConf() {
return confCache.value; 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) { function readConf(file) {
let text; let text;
try { try {
@@ -56,16 +63,28 @@ function readConf(file) {
const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || ""; const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || "";
const usersRoot = get("USERS_ROOT"); const usersRoot = get("USERS_ROOT");
if (!path.isAbsolute(usersRoot)) return null; 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 { return {
hostUser: get("HOST_USER"), hostUser: get("HOST_USER"),
hostUid: Number(get("HOST_UID")), hostUid: Number(get("HOST_UID")),
usersRoot, usersRoot,
port: Number(get("PORT")) || null, 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 // The absolute paths sudoers lists (sudo would resolve a bare name on its
// own secure_path, which may not be them). // own secure_path, which may not be them).
bins: Object.fromEntries( bins: Object.fromEntries(
[ [
["tmux", get("TMUX_BIN")], ["tmux", tmuxBin],
["git", get("GIT_BIN")], ["git", get("GIT_BIN")],
["gh", get("GH_BIN")], ["gh", get("GH_BIN")],
].filter(([, v]) => path.isAbsolute(v)) ].filter(([, v]) => path.isAbsolute(v))
@@ -154,6 +173,7 @@ function __resetConfCache() {
module.exports = { module.exports = {
__resetConfCache, __resetConfCache,
__setTmuxMember,
confPath, confPath,
LINUX_NAME_RE, LINUX_NAME_RE,
serverConf, 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); 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. */ /** Session names starting with `prefix`. Empty array if tmux has no server running at all. */
function listSessions(prefix) { function listSessions(prefix) {
const names = []; const names = [];
@@ -867,6 +914,8 @@ module.exports = {
upgradeAdvice, upgradeAdvice,
serverPid, serverPid,
memberAttach, memberAttach,
memberServerInfo,
killMemberServer,
__setExecImpl, __setExecImpl,
__setAccountImpl, __setAccountImpl,
__settle, __settle,
+73
View File
@@ -14,6 +14,8 @@
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture * - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form * - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst) * - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
* Exports `{ tags, schemas, paths }`, combined into the spec by * Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md. * server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -409,6 +411,77 @@ const paths = {
}, },
}, },
}, },
"/api/users/tmux": {
get: {
tags: ["Auth"],
operationId: "listMemberTmux",
summary:
"Each member's own tmux server (one Linux user per account): its version beside the installed one, sessions, agents working (admin/analyst)",
responses: {
200: {
description:
"{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]} — available false without S3",
},
403: err("EADMIN — a developer"),
},
},
},
"/api/users/{id}/tmux-restart": {
post: {
tags: ["Auth"],
operationId: "restartMemberTmux",
summary:
"End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)",
parameters: [idParam],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword"],
properties: { currentPassword: { type: "string" } },
},
},
},
},
responses: {
200: { description: "{user}" },
403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"),
404: err("ENOUSER"),
409: err(
"ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3"
),
},
},
},
"/api/users/tmux-restart-idle": {
post: {
tags: ["Auth"],
operationId: "restartIdleMemberTmux",
summary:
"Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword"],
properties: { currentPassword: { type: "string" } },
},
},
},
},
responses: {
200: {
description:
"{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}",
},
403: err("EREAUTH / EADMIN / ELOCALMODE"),
},
},
},
"/api/users/{id}/avatar": { "/api/users/{id}/avatar": {
get: { get: {
tags: ["Auth"], tags: ["Auth"],
+81
View File
@@ -8,6 +8,12 @@
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes * (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). * (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` 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 * `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
* owner groups — staff only like the rest of `/users`. * 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) }); 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 = { const STATUS = {
EBADUSER: 400, EBADUSER: 400,
EBADEMAIL: 400, EBADEMAIL: 400,
@@ -114,6 +125,9 @@ const STATUS = {
ETOOMANY: 429, ETOOMANY: 429,
// S3: the Linux account could not be created/locked (the helper said why). // S3: the Linux account could not be created/locked (the helper said why).
EPROVISION: 502, 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( router.post(
"/:id/password", "/:id/password",
accountChange(async (req, body) => { accountChange(async (req, body) => {