Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a58e95a957 | |||
| 4d6067650a | |||
| 5bc3f7d34a | |||
| b058179177 | |||
| b6b40b0c1b | |||
| 758609775f | |||
| 09efb089cf | |||
| c83ddf62a0 |
+58
-6
@@ -3934,21 +3934,73 @@ async function cmdServer(sub, flags) {
|
||||
} else {
|
||||
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
|
||||
}
|
||||
const wasServer = auth.isServerMode();
|
||||
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
|
||||
envNote("AGENTHUB_MODE");
|
||||
console.log(`${c.green("✔")} server mode on (${file})`);
|
||||
// One Linux user per account (S3) is the default on Linux: install it now
|
||||
// (one sudo), unless it is already in place or `--no-system` opts out.
|
||||
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
|
||||
const shared = flags["no-system"] === true || process.platform !== "linux";
|
||||
if (!shared) {
|
||||
if (linux.serverConf()) {
|
||||
console.log(c.dim(" one Linux user per account is already installed"));
|
||||
// The system unit serves; it reads the mode only when it starts.
|
||||
if (!wasServer) {
|
||||
const r = spawnSync("sudo", [
|
||||
"-n",
|
||||
"systemctl",
|
||||
"restart",
|
||||
"--no-block",
|
||||
"agenthub.service",
|
||||
]);
|
||||
console.log(
|
||||
r.status === 0
|
||||
? `${c.green("✔")} agenthub.service restarted`
|
||||
: c.yellow("! restart it: sudo systemctl restart agenthub")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(" Installing one Linux user per account (asks for your sudo password)…");
|
||||
try {
|
||||
await installSystem({}, settingsEnv);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`${e.message}\n Server mode is on, but every pane still runs as this account.` +
|
||||
"\n Fix it and run `agenthub server install-system`, or keep one shared" +
|
||||
" account with `agenthub server setup --no-system`."
|
||||
);
|
||||
}
|
||||
}
|
||||
// The admin made above (and anyone made before) gets their Linux account.
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
await cmdUsers("provision", [], { all: true });
|
||||
}
|
||||
console.log(" Next:");
|
||||
console.log(
|
||||
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||
shared
|
||||
? " 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||
: " 1. Log out and back in once, so your shell joins the agenthub groups"
|
||||
);
|
||||
console.log(
|
||||
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
|
||||
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
|
||||
);
|
||||
console.log(
|
||||
c.dim(" Members see only their own workspaces, but every pane still runs as this")
|
||||
);
|
||||
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
|
||||
if (shared) {
|
||||
console.log(
|
||||
c.yellow(
|
||||
process.platform === "linux"
|
||||
? "! --no-system: every pane runs as this one Linux account"
|
||||
: "! one Linux user per account needs Linux: every pane runs as this one account"
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
c.dim(" Members see only their own workspaces, but an agent in a pane can read")
|
||||
);
|
||||
console.log(
|
||||
c.dim(" every user's files — use it with people you trust (docs/SERVER-MODE.md).")
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
|
||||
);
|
||||
@@ -3989,7 +4041,7 @@ async function cmdServer(sub, flags) {
|
||||
}
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||
"✖ Usage: agenthub server setup [--hostname name] [--admin name] [--no-system] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
|
||||
+7
-3
@@ -89,7 +89,9 @@ const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||
const Administration = lazy(() =>
|
||||
import("./pages/Administration").then((m) => ({ default: m.Administration }))
|
||||
);
|
||||
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
||||
|
||||
function PageFallback() {
|
||||
@@ -237,13 +239,15 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
path="admin"
|
||||
element={
|
||||
<Page>
|
||||
<Users />
|
||||
<Administration />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
{/* The Users page became Administration's first tab. */}
|
||||
<Route path="users" element={<Navigate to="/admin" replace />} />
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -479,11 +479,11 @@ export function Sidebar({
|
||||
{[
|
||||
// The file manager serves a shared server; locally it is the desktop's.
|
||||
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
// Server mode: Administration (users, server), for an admin or analyst.
|
||||
...(auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
auth.state.user.role !== "developer"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* @file ServerPanel.tsx
|
||||
* @description The Administration page's Server tab: the machine AgentHub
|
||||
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
||||
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
||||
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
||||
* machine, resident memory, process count, the busiest processes by name, and
|
||||
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
||||
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
||||
* at once. Read-only — the only action here is the members' tmux card
|
||||
* (`MemberTmuxCard`, an admin's restart).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
MonitorCog,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { StatCard } from "../StatCard";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Names, pictures and home sizes: read once, they change slowly.
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
api.users
|
||||
.storage()
|
||||
.then((r) => setStorage(r.storage))
|
||||
.catch(() => setStorage({}));
|
||||
}, []);
|
||||
|
||||
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||
const loading = !stats && !error;
|
||||
const mem = stats?.memory;
|
||||
const toggle = (key: string) =>
|
||||
setOpen((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("server.hostGroup");
|
||||
if (g.kind === "system") return t("server.systemGroup");
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
return u?.displayName || g.username || "";
|
||||
};
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="server-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
||||
</h2>
|
||||
{stats && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
||||
{" · "}
|
||||
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("server.cpu")}
|
||||
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
||||
icon={Cpu}
|
||||
trend={
|
||||
stats
|
||||
? t("server.cores", {
|
||||
n: stats.cpu.cores,
|
||||
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
raw={stats?.cpu.model ?? undefined}
|
||||
help={t("server.cpuHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.memory")}
|
||||
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
||||
icon={MemoryStick}
|
||||
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.swap")}
|
||||
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
||||
icon={MonitorCog}
|
||||
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.homesDisk")}
|
||||
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
||||
icon={HardDrive}
|
||||
trend={
|
||||
homesDisk
|
||||
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
||||
: undefined
|
||||
}
|
||||
help={t("server.homesDiskHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</p>
|
||||
</div>
|
||||
{stats && !stats.usage ? (
|
||||
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.who")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
||||
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
||||
{t("server.sampling")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{stats?.usage?.groups.map((g) => {
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
||||
const isOpen = open.has(g.key);
|
||||
const name = groupName(g);
|
||||
return (
|
||||
<Fragment key={g.key}>
|
||||
<tr className="hover:bg-surface-4/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
{g.top.length ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
{g.kind === "user" ? (
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={u?.avatarAt}
|
||||
userId={g.userId}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
||||
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
||||
{g.kind === "user" && u?.displayName && (
|
||||
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
||||
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(g.memory)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
||||
</div>
|
||||
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{g.processes}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{home ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(home.bytes)}
|
||||
{homesDisk && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
({fmtPct(pct(home.bytes, homesDisk.total))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{homesDisk && (
|
||||
<Meter
|
||||
value={pct(home.bytes, homesDisk.total)}
|
||||
label={`${name} ${t("server.home")}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-fg-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen &&
|
||||
g.top.map((p) => (
|
||||
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
||||
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{fmtPct(p.cpu)}
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{formatBytes(p.memory)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.disks.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("server.disks")}
|
||||
</h3>
|
||||
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.mount")}</th>
|
||||
<th className={th}>{t("server.device")}</th>
|
||||
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.free")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{stats.disks.map((d) => (
|
||||
<tr key={d.device}>
|
||||
<td className="px-5 py-3 text-sm text-fg-primary">
|
||||
<span className="font-mono">{d.mount}</span>
|
||||
{d.mount === stats.homesMount && (
|
||||
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-fg-secondary">
|
||||
<span className="font-mono">{d.device}</span> · {d.type}
|
||||
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
||||
</div>
|
||||
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{formatBytes(d.free)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.gpus.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
||||
{stats.gpus.map((g, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-fg-primary">{g.name}</span>
|
||||
<span className="text-fg-muted tabular-nums">
|
||||
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
||||
{formatBytes(g.memoryTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<Meter value={g.utilization} label={g.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,12 @@
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
||||
* Skip. Server side: `/api/lanes/:id/fs/*` (server/lib/file-explorer.js).
|
||||
* Skip. Deleting forever asks first when it empties the Trash or touches
|
||||
* another user's. An admin's Trash opens on one folder per user (with its
|
||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||
* without waiting the 7 days.
|
||||
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
||||
* (server/lib/file-explorer.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -82,6 +87,7 @@ import {
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
@@ -96,6 +102,11 @@ type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||
let clipboard: Clip | null = null;
|
||||
|
||||
/** A menu item's handler that closes its menu first. */
|
||||
const pick = (m: { close: () => void }, fn: () => void) => () => {
|
||||
m.close();
|
||||
fn();
|
||||
};
|
||||
/** The workspace id of a place, or null for a home. */
|
||||
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
|
||||
/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
|
||||
@@ -250,6 +261,10 @@ export function FileBrowser({
|
||||
const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1");
|
||||
const [query, setQuery] = useState<string | null>(null);
|
||||
const [trash, setTrash] = useState<FsTrashItem[]>([]);
|
||||
// An admin's Trash opens on its root: one folder per user's Trash, counted
|
||||
// from `GET /api/homes/trash` (a Trash that could not be read: its error).
|
||||
const [trashRoot, setTrashRoot] = useState(false);
|
||||
const [trashErrors, setTrashErrors] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
||||
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
||||
@@ -269,6 +284,9 @@ export function FileBrowser({
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const folderInput = useRef<HTMLInputElement>(null);
|
||||
const ctx = useContextMenu<FsEntry | null>();
|
||||
// The sidebar's places and the path bar's crumbs have their own menus.
|
||||
const placeCtx = useContextMenu<{ home: string } | { lane: Lane }>();
|
||||
const crumbCtx = useContextMenu<string>();
|
||||
|
||||
// A new caller target (another workspace's Files tab, the page's URL) —
|
||||
// not the echo of our own `go()` through the page's URL, which would wipe
|
||||
@@ -330,7 +348,12 @@ export function FileBrowser({
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
if (view === "trash" && trashRoot) {
|
||||
const out = await api.files.allTrash();
|
||||
setTrash(out.items);
|
||||
// A user whose trash could not be read is said so on their folder.
|
||||
setTrashErrors(Object.fromEntries((out.errors ?? []).map((e) => [e.home, e.message])));
|
||||
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
else setListing(await api.files.list(target, path));
|
||||
} catch (e) {
|
||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||
@@ -338,10 +361,11 @@ export function FileBrowser({
|
||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||
);
|
||||
if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
|
||||
if (view === "trash") setTrash([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [target, path, view, t]);
|
||||
}, [target, path, view, trashRoot, t]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -635,11 +659,82 @@ export function FileBrowser({
|
||||
|
||||
const homeName = (who: string) =>
|
||||
who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who);
|
||||
/** Delete forever — asked first when it is all of it or someone else's. */
|
||||
const purge = async (id: string) => {
|
||||
if (id === "all" || ro) {
|
||||
const item = trash.find((i) => i.id === id);
|
||||
const ok = await confirmDialog({
|
||||
title: id === "all" ? t("fx.emptyTrashTitle") : t("fx.purgeTitle"),
|
||||
message:
|
||||
id === "all"
|
||||
? t("fx.emptyTrashBody", { count: trash.length, where: trashLabel })
|
||||
: t("fx.purgeBody", { name: item?.path ?? id, where: trashLabel }),
|
||||
confirmLabel: id === "all" ? t("fx.emptyTrash") : t("fx.purge"),
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
const item = trash.find((i) => i.id === id);
|
||||
await run(() => api.files.purge(target, id, item?.store), t("fx.purged"));
|
||||
};
|
||||
/** Empty one user's whole Trash from the Trash root — asked first. */
|
||||
const emptyTrashOf = async (home: string, count: number) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("fx.emptyTrashTitle"),
|
||||
message: t("fx.emptyTrashBody", {
|
||||
count,
|
||||
where: home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(home) }),
|
||||
}),
|
||||
confirmLabel: t("fx.emptyTrash"),
|
||||
});
|
||||
if (ok) await run(() => api.files.purge({ home }, "all"), t("fx.purged"));
|
||||
};
|
||||
const restoreItem = (id: string) => {
|
||||
const item = trash.find((i) => i.id === id);
|
||||
void run(() => api.files.restore(target, id, item?.store), t("fx.restored"));
|
||||
};
|
||||
/**
|
||||
* Open a Trash. Every user has ONE recycle bin — their home's, over every
|
||||
* place they deleted in — so `home` opens that user's ("me": the caller's);
|
||||
* null (no home to open) falls back to the trash of the place shown, and
|
||||
* "*" opens the root an admin starts on: one folder per user's Trash.
|
||||
*/
|
||||
const openTrash = (home: string | null) => {
|
||||
if (home === "*") {
|
||||
setTrashRoot(true);
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
return;
|
||||
}
|
||||
setTrashRoot(false);
|
||||
if (home && !(isHomePlace(target) && target.home === home)) {
|
||||
setTarget({ home });
|
||||
setPathState("");
|
||||
setHistory({ back: [], fwd: [] });
|
||||
onNavigate?.({ home }, "");
|
||||
}
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
};
|
||||
// Someone else's Trash can be opened: the Trash starts on its folders.
|
||||
const manyTrashes = homes.some((h) => h.id !== "me");
|
||||
const openTrashes = () =>
|
||||
openTrash(manyTrashes ? "*" : homes.some((h) => h.id === "me") ? "me" : null);
|
||||
const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!));
|
||||
const laneName = (id: number) => {
|
||||
const l = lanes.find((x) => x.id === id);
|
||||
return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`;
|
||||
};
|
||||
// A home's Trash is its USER's recycle bin, named after them: another
|
||||
// user's shows THAT user's (an admin may restore from or purge it; the rest
|
||||
// of the home stays read-only). A workspace's (no home to open) is the
|
||||
// trash of whoever owns its folder.
|
||||
const trashLabel = trashRoot
|
||||
? t("fx.trash")
|
||||
: !isHomePlace(target)
|
||||
? t("fx.trashAt", { name: laneName(laneOf(target)!) })
|
||||
: target.home === "me"
|
||||
? t("fx.myTrash")
|
||||
: t("fx.trashOf", { name: homeName(target.home) });
|
||||
const crumbs = path ? path.split("/") : [];
|
||||
const menuItem = ctx.menu?.item ?? null;
|
||||
const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
|
||||
@@ -697,6 +792,7 @@ export function FileBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("", { home: "me" })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === "me"
|
||||
)}
|
||||
@@ -719,6 +815,7 @@ export function FileBrowser({
|
||||
type="button"
|
||||
title={t("fx.readOnlyHome")}
|
||||
onClick={() => go("", { home: h.id })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
@@ -739,6 +836,7 @@ export function FileBrowser({
|
||||
type="button"
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}
|
||||
@@ -748,17 +846,10 @@ export function FileBrowser({
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-border p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className={placeClass(view === "trash")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("fx.trash")}
|
||||
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{t("fx.trash")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -774,8 +865,8 @@ export function FileBrowser({
|
||||
</HeadButton>
|
||||
<HeadButton
|
||||
label={t("fx.up")}
|
||||
disabled={view !== "files" || listing?.parent == null}
|
||||
onClick={goUp}
|
||||
disabled={view === "trash" ? trashRoot || !manyTrashes : listing?.parent == null}
|
||||
onClick={view === "trash" ? () => openTrash("*") : goUp}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
@@ -805,15 +896,36 @@ export function FileBrowser({
|
||||
aria-label={t("fx.location")}
|
||||
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md bg-surface-2 px-1 py-0.5"
|
||||
>
|
||||
{view === "trash" ? (
|
||||
{view === "trash" && manyTrashes ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTrash("*")}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
trashRoot ? "font-semibold" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
|
||||
</button>
|
||||
{!trashRoot && (
|
||||
<span className="flex shrink-0 items-center">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="px-2 py-1 text-sm font-semibold">
|
||||
{isHomePlace(target) ? homeName(target.home) : trashLabel}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : view === "trash" ? (
|
||||
<span className="flex items-center gap-1.5 px-2 py-1 text-sm font-medium">
|
||||
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
|
||||
<Trash2 className="h-4 w-4" /> {trashLabel}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("")}
|
||||
onContextMenu={(e) => crumbCtx.open(e, "")}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => void onDrop(e, "")}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
@@ -833,6 +945,7 @@ export function FileBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
|
||||
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
|
||||
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
@@ -1169,12 +1282,26 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : trashRoot ? (
|
||||
<TrashFolders
|
||||
homes={homes}
|
||||
items={trash}
|
||||
errors={trashErrors}
|
||||
error={error}
|
||||
loading={loading}
|
||||
homeName={homeName}
|
||||
onOpen={openTrash}
|
||||
onEmpty={(home, count) => void emptyTrashOf(home, count)}
|
||||
onRefresh={() => void load()}
|
||||
/>
|
||||
) : (
|
||||
<TrashView
|
||||
items={trash}
|
||||
error={error}
|
||||
laneName={laneName}
|
||||
onRestore={(id) => void run(() => api.files.restore(target, id), t("fx.restored"))}
|
||||
onPurge={(id) => void run(() => api.files.purge(target, id))}
|
||||
onRestore={restoreItem}
|
||||
onPurge={(id) => void purge(id)}
|
||||
onRefresh={() => void load()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1350,6 +1477,78 @@ export function FileBrowser({
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{placeCtx.menu && (
|
||||
<ContextMenu
|
||||
x={placeCtx.menu.x}
|
||||
y={placeCtx.menu.y}
|
||||
onClose={placeCtx.close}
|
||||
testId="fx-place-menu"
|
||||
>
|
||||
{"lane" in placeCtx.menu.item
|
||||
? (() => {
|
||||
const l = placeCtx.menu.item.lane;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(placeCtx, () => go("", l.id))}>
|
||||
{t("fx.open")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(placeCtx, () => void copyText(l.cwd))}>
|
||||
{t("fx.copyPath")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
: (() => {
|
||||
const home = (placeCtx.menu.item as { home: string }).home;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(placeCtx, () => go("", { home }))}>
|
||||
{t("fx.open")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(placeCtx, () => openTrash(home))}>
|
||||
{t("fx.openTrash")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{crumbCtx.menu && (
|
||||
<ContextMenu
|
||||
x={crumbCtx.menu.x}
|
||||
y={crumbCtx.menu.y}
|
||||
onClose={crumbCtx.close}
|
||||
testId="fx-crumb-menu"
|
||||
>
|
||||
{(() => {
|
||||
const dir = crumbCtx.menu.item;
|
||||
const name = dir.split("/").pop() || placeName(target);
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(crumbCtx, () => go(dir))}>{t("fx.open")}</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pick(crumbCtx, () =>
|
||||
download([{ name, path: dir, type: "dir", size: null, mtime: 0 }])
|
||||
)}
|
||||
>
|
||||
{t("fx.downloadZip")}
|
||||
</MenuItem>
|
||||
{!ro && clip && (
|
||||
<MenuItem onSelect={pick(crumbCtx, () => void paste(dir))}>
|
||||
{t("fx.pasteInto")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pick(crumbCtx, () => void copyText(dir || "/"))}>
|
||||
{t("fx.copyPath")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{ask && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
|
||||
@@ -1651,20 +1850,157 @@ function InlineName({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An admin's Trash root: one folder per user's Trash (theirs first), each
|
||||
* with its item count — or why it could not be read. Opening one shows that
|
||||
* user's Trash, where its items are restored or deleted forever.
|
||||
*/
|
||||
function TrashFolders({
|
||||
homes,
|
||||
items,
|
||||
errors,
|
||||
error,
|
||||
loading,
|
||||
homeName,
|
||||
onOpen,
|
||||
onEmpty,
|
||||
onRefresh,
|
||||
}: {
|
||||
homes: FsHome[];
|
||||
/** Every Trash's items, merged, each tagged with its `home`. */
|
||||
items: FsTrashItem[];
|
||||
/** Per home id: why its Trash could not be read. */
|
||||
errors: Record<string, string>;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
homeName: (id: string) => string;
|
||||
onOpen: (home: string) => void;
|
||||
onEmpty: (home: string, count: number) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const menu = useContextMenu<string | null>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1);
|
||||
const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")];
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="fx-trash-root"
|
||||
onContextMenu={(e) => menu.open(e, null)}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
||||
{t("fx.trashFoldersHint")}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-2 overflow-y-auto p-4">
|
||||
{ordered.map((h) => {
|
||||
const n = counts.get(h.id) ?? 0;
|
||||
const failed = errors[h.id];
|
||||
const name = h.id === "me" ? t("fx.myTrash") : homeName(h.id);
|
||||
return (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
|
||||
onClick={() => onOpen(h.id)}
|
||||
onContextMenu={(e) => menu.open(e, h.id)}
|
||||
className="flex flex-col items-center gap-1 rounded-lg px-2 py-3 text-center hover:bg-surface-2"
|
||||
data-testid="fx-trash-folder"
|
||||
>
|
||||
<span className="relative">
|
||||
<Folder
|
||||
className={`h-14 w-14 ${
|
||||
n ? "fill-accent/25 text-accent" : "fill-surface-3 text-fg-muted"
|
||||
}`}
|
||||
/>
|
||||
{h.id === "me" ? (
|
||||
<House className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
|
||||
) : (
|
||||
<UserRound className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
|
||||
)}
|
||||
</span>
|
||||
<span className="w-full truncate text-[13px] font-medium">{name}</span>
|
||||
<span
|
||||
className={`w-full truncate text-[11px] ${
|
||||
failed ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
title={failed}
|
||||
>
|
||||
{failed
|
||||
? failed
|
||||
: loading
|
||||
? "…"
|
||||
: n
|
||||
? t("fx.itemsCount", { count: n })
|
||||
: t("fx.trashFolderEmpty")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{menu.menu && (
|
||||
<ContextMenu
|
||||
x={menu.menu.x}
|
||||
y={menu.menu.y}
|
||||
onClose={menu.close}
|
||||
testId="fx-trash-root-menu"
|
||||
>
|
||||
{menu.menu.item !== null ? (
|
||||
(() => {
|
||||
const home = menu.menu.item;
|
||||
const n = counts.get(home) ?? 0;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(menu, () => onOpen(home))}>{t("fx.open")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger disabled={!n} onSelect={pick(menu, () => onEmpty(home, n))}>
|
||||
{t("fx.emptyTrash")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashView({
|
||||
items,
|
||||
error,
|
||||
laneName,
|
||||
onRestore,
|
||||
onPurge,
|
||||
onRefresh,
|
||||
}: {
|
||||
items: FsTrashItem[];
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
laneName: (id: number) => string;
|
||||
onRestore: (id: string) => void;
|
||||
onPurge: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const menu = useContextMenu<FsTrashItem | null>();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="fx-trash"
|
||||
onContextMenu={(e) => menu.open(e, null)}
|
||||
>
|
||||
{error && (
|
||||
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
||||
<span className="flex-1">{t("fx.trashHint")}</span>
|
||||
<button
|
||||
@@ -1692,6 +2028,7 @@ function TrashView({
|
||||
key={i.id}
|
||||
className="border-b border-border/40 hover:bg-surface-2"
|
||||
data-testid="fx-trash-row"
|
||||
onContextMenu={(e) => menu.open(e, i)}
|
||||
>
|
||||
<td className="truncate px-4 py-1.5">
|
||||
<span className="flex items-center gap-2" title={i.path}>
|
||||
@@ -1732,13 +2069,44 @@ function TrashView({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!items.length && (
|
||||
{!items.length && !error && (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-fg-muted">
|
||||
<Trash2 className="h-14 w-14 opacity-40" />
|
||||
<p className="text-sm">{t("fx.trashEmpty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{menu.menu && (
|
||||
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="fx-trash-menu">
|
||||
{menu.menu.item ? (
|
||||
(() => {
|
||||
const i = menu.menu.item;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(menu, () => onRestore(i.id))}>
|
||||
{t("fx.restore")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(menu, () => void copyText(i.path))}>
|
||||
{t("fx.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick(menu, () => onPurge(i.id))}>
|
||||
{t("fx.purge")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger disabled={!items.length} onSelect={pick(menu, () => onPurge("all"))}>
|
||||
{t("fx.emptyTrash")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
||||
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
||||
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
|
||||
* batch over 500 MB before sending anything, and restores from the Trash.
|
||||
* batch over 500 MB before sending anything, restores from the user's own
|
||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||
* one saying why), opens each user's Trash from its folder (asking before a
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -12,6 +16,9 @@ import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
@@ -32,6 +39,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
copy: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
trash: vi.fn(),
|
||||
allTrash: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
@@ -70,6 +78,7 @@ beforeEach(() => {
|
||||
} as never);
|
||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
|
||||
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({ items: [], errors: [] });
|
||||
vi.mocked(api.files.homes).mockResolvedValue({
|
||||
homes: [
|
||||
{ id: "me", label: "An", readOnly: false },
|
||||
@@ -212,10 +221,178 @@ describe("FileExplorer", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(screen.getByRole("button", { name: "Trash" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
|
||||
await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith(1, "abc12345"));
|
||||
await waitFor(() =>
|
||||
expect(api.files.restore).toHaveBeenCalledWith({ home: "me" }, "abc12345", undefined)
|
||||
);
|
||||
});
|
||||
|
||||
it("opens an admin's Trash on one folder per user, each opening that user's Trash", async () => {
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
home: "bob",
|
||||
owner: "Bob",
|
||||
},
|
||||
],
|
||||
errors: [{ home: "me", owner: "An", message: "EMEMBERGONE" }],
|
||||
});
|
||||
vi.mocked(api.files.trash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const trashes = screen.getByTestId("fx-trashes");
|
||||
expect(within(trashes).getAllByRole("button")).toHaveLength(1);
|
||||
await user.click(within(trashes).getByRole("button", { name: "Trash" }));
|
||||
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||
expect(folders.map((f) => f.textContent)).toEqual(["My TrashEMEMBERGONE", "Bob1 item(s)"]);
|
||||
expect(screen.queryByTestId("fx-trash-row")).toBeNull();
|
||||
await user.click(folders[1]!);
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "bob" }));
|
||||
expect(screen.getByRole("navigation", { name: "Location" }).textContent).toContain("Bob");
|
||||
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Bob's Trash") })
|
||||
)
|
||||
);
|
||||
expect(api.files.purge).not.toHaveBeenCalled();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||
);
|
||||
// Back to the folders through the path bar.
|
||||
await user.click(
|
||||
within(screen.getByRole("navigation", { name: "Location" })).getByRole("button", {
|
||||
name: "Trash",
|
||||
})
|
||||
);
|
||||
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => {
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
home: "bob",
|
||||
owner: "Bob",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
vi.mocked(api.files.trash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||
// My Trash is empty: its Empty Trash is disabled.
|
||||
fireEvent.contextMenu(folders[0]!);
|
||||
expect(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||
name: "Empty Trash",
|
||||
})
|
||||
).toHaveProperty("disabled", true);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
fireEvent.contextMenu(folders[1]!);
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||
name: "Empty Trash",
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all"));
|
||||
expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull();
|
||||
|
||||
fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!);
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" })
|
||||
);
|
||||
fireEvent.contextMenu(await screen.findByTestId("fx-trash-row"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||
);
|
||||
});
|
||||
|
||||
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "two" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" })
|
||||
);
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Home" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" })
|
||||
);
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||
});
|
||||
|
||||
it("shows why a Trash could not be read instead of an empty one", async () => {
|
||||
vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||
expect(await screen.findByText(/ENOENT: no such folder/)).toBeTruthy();
|
||||
expect(screen.queryByText("The Trash is empty.")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
|
||||
@@ -242,7 +419,7 @@ describe("FileExplorer", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(await screen.findByRole("button", { name: /Bob/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Bob" }));
|
||||
expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "New" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
* @file GitIdentitySettings.tsx
|
||||
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
|
||||
* commit author (name + email in the account's global git config — prefilled
|
||||
* from Settings → Account while unset, since a member's commits fail without
|
||||
* one), the SSH
|
||||
* from Settings → Account while unset, the email else `<username>@smartgift.vn`,
|
||||
* since a member's commits fail without one; a new SSH key is labelled with
|
||||
* that email), the SSH
|
||||
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||
* it, and whether git typed in the account's own terminal uses it too (a
|
||||
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
|
||||
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
|
||||
* that same file, so a plain `ssh://git-codecommit…` URL works) — and, for
|
||||
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
|
||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||
* kind of host, the extra keys, the other hosts) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
@@ -19,12 +24,23 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { PageTabs } from "../PageTabs";
|
||||
import { Switch } from "../Switch";
|
||||
|
||||
const INPUT =
|
||||
@@ -176,6 +192,8 @@ export function GitIdentitySettings() {
|
||||
const me = auth.mode === "server" ? auth.user : null;
|
||||
// null = not edited: the field shows the saved value, else the account's.
|
||||
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
|
||||
// null = not picked: opens on the first kind of host that has a key.
|
||||
const [sshTab, setSshTab] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -205,9 +223,36 @@ export function GitIdentitySettings() {
|
||||
|
||||
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
||||
// An account may hold one of each; an older server answers `ssh` only.
|
||||
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
||||
const keyOf = (type: "ed25519" | "rsa") => keys.find((k) => (k.type ?? "ed25519") === type);
|
||||
// The default key of a type (a named key sits beside it).
|
||||
const keyOf = (type: "ed25519" | "rsa") =>
|
||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||
const named = keys.filter((k) => k.name);
|
||||
const keyLabel = (keyId: string | null | undefined) => {
|
||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||
if (!k) return t("gitIdentity.anyKey");
|
||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||
};
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
||||
const makeNamed = async () => {
|
||||
if (!newKey) return;
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
if (ok) setNewKey(null);
|
||||
};
|
||||
const removeNamed = async (keyId: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeKeyById(keyId));
|
||||
};
|
||||
const generate = async (type: "ed25519" | "rsa") => {
|
||||
const taken = !!keyOf(type);
|
||||
if (
|
||||
@@ -259,7 +304,7 @@ export function GitIdentitySettings() {
|
||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||
});
|
||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1" });
|
||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
||||
const saveAws = async () => {
|
||||
let user = aws.keyId.trim();
|
||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||
@@ -269,23 +314,55 @@ export function GitIdentitySettings() {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
}
|
||||
const alias = aws.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.setSshUser({ host, user }),
|
||||
t("gitIdentity.awsSaved", { region: CODECOMMIT_RE.exec(host)?.[1] ?? host })
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(aws.key ? { key: aws.key } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
);
|
||||
if (ok) setAws({ ...aws, keyId: "" });
|
||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
||||
};
|
||||
const [other, setOther] = useState({ host: "", user: "" });
|
||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||
const saveOther = async () => {
|
||||
let host = other.host.trim();
|
||||
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
|
||||
if (m?.[1]) host = m[1];
|
||||
const alias = other.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.setSshUser({ host, user: other.user.trim() }),
|
||||
t("gitIdentity.otherSaved", { host })
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user: other.user.trim(),
|
||||
...(alias ? { alias } : {}),
|
||||
...(other.key ? { key: other.key } : {}),
|
||||
}),
|
||||
t("gitIdentity.otherSaved", { host: alias || host })
|
||||
);
|
||||
if (ok) setOther({ host: "", user: "" });
|
||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||
};
|
||||
/** The key picker of a login form: every key, or one. */
|
||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
||||
<select
|
||||
aria-label={t("gitIdentity.loginKey")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||
{keys
|
||||
.filter((k) => k.id && (!only || k.type === only))
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
const removeLogin = async (host: string) => {
|
||||
if (
|
||||
@@ -308,13 +385,38 @@ export function GitIdentitySettings() {
|
||||
const member = id.account === "member";
|
||||
const authorShown = author ?? {
|
||||
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
|
||||
email: id.author.email ?? me?.email ?? "",
|
||||
email: id.author.email ?? me?.email ?? id.defaultEmail ?? "",
|
||||
};
|
||||
const authorUnset = !id.author.name || !id.author.email;
|
||||
const authorDirty =
|
||||
authorUnset ||
|
||||
authorShown.name.trim() !== (id.author.name ?? "") ||
|
||||
authorShown.email.trim() !== (id.author.email ?? "");
|
||||
// The SSH keys, one tab per kind of host — every section at once was a wall.
|
||||
const sshTabs = [
|
||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||
...(named.length > 0 || id.writable
|
||||
? [
|
||||
{
|
||||
id: "more",
|
||||
label: named.length
|
||||
? `${t("gitIdentity.tabMore")} (${named.length})`
|
||||
: t("gitIdentity.tabMore"),
|
||||
Icon: KeySquare,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||
: []),
|
||||
];
|
||||
const activeSshTab =
|
||||
sshTab && sshTabs.some((x) => x.id === sshTab)
|
||||
? sshTab
|
||||
: !keyOf("ed25519") && keyOf("rsa")
|
||||
? "aws"
|
||||
: "git";
|
||||
const saveAuthor = async () => {
|
||||
const ok = await run(
|
||||
() =>
|
||||
@@ -389,50 +491,262 @@ export function GitIdentitySettings() {
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
|
||||
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
type="ed25519"
|
||||
k={keyOf("ed25519")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
<PageTabs
|
||||
ariaLabel={t("gitIdentity.sshTitle")}
|
||||
tabs={sshTabs}
|
||||
activeId={activeSshTab}
|
||||
onSelect={setSshTab}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
{activeSshTab === "git" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
type="ed25519"
|
||||
k={keyOf("ed25519")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
)}
|
||||
|
||||
<KeyCard
|
||||
title={t("gitIdentity.awsTitle")}
|
||||
sub={t("gitIdentity.awsSub")}
|
||||
type="rsa"
|
||||
k={keyOf("rsa")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.host} className="mt-1 flex items-center gap-2">
|
||||
{activeSshTab === "aws" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.awsTitle")}
|
||||
sub={t("gitIdentity.awsSub")}
|
||||
type="rsa"
|
||||
k={keyOf("rsa")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.alias ? `${r.alias} · ` : ""}
|
||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={aws.keyId}
|
||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={aws.region}
|
||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={aws.alias}
|
||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{awsRows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{awsRows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</KeyCard>
|
||||
)}
|
||||
|
||||
{activeSshTab === "more" && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.moreKeysTitle")}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||
</div>
|
||||
{id.writable && !newKey && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="rsa">RSA 4096 (AWS)</option>
|
||||
<option value="ed25519">ED25519</option>
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||
{t("gitIdentity.generate")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="space-y-1 rounded border border-border-light p-2"
|
||||
data-testid="git-named-key"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
value={k.publicKey}
|
||||
rows={2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeSshTab === "other" && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.otherTitle")}
|
||||
className="space-y-1.5 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.otherTitle")}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
|
||||
{otherRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.region} · {r.user}
|
||||
{r.alias ? `ssh://${r.alias}/ → ` : ""}
|
||||
{r.user}@{r.host} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
|
||||
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -441,49 +755,50 @@ export function GitIdentitySettings() {
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
void saveOther();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={aws.keyId}
|
||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||
aria-label={t("gitIdentity.otherHost")}
|
||||
placeholder="git.example.com"
|
||||
value={other.host}
|
||||
onChange={(e) => setOther({ ...other, host: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={aws.region}
|
||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherUser")}
|
||||
placeholder={t("gitIdentity.otherUser")}
|
||||
autoComplete="off"
|
||||
value={other.user}
|
||||
onChange={(e) => setOther({ ...other, user: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={other.alias}
|
||||
onChange={(e) => setOther({ ...other, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(other.key, (key) => setOther({ ...other, key }))}
|
||||
<button
|
||||
type="submit"
|
||||
className={BTN}
|
||||
disabled={busy || !other.host.trim() || !other.user.trim()}
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{awsRows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{awsRows.map((r) => (
|
||||
<Code key={r.host}>git clone ssh://{r.host}/v1/repos/<repo></Code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</KeyCard>
|
||||
</PageTabs>
|
||||
|
||||
{terminal && (
|
||||
<div className="flex items-start gap-2 rounded border border-border p-2">
|
||||
@@ -503,67 +818,6 @@ export function GitIdentitySettings() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{id.sshUsers !== undefined && (otherRows.length > 0 || id.writable) && (
|
||||
<details className="rounded border border-border p-2" open={otherRows.length > 0}>
|
||||
<summary className="cursor-pointer text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.otherTitle")}
|
||||
</summary>
|
||||
<div className="mt-1.5 space-y-1.5">
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
|
||||
{otherRows.map((r) => (
|
||||
<div key={r.host} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.user}@{r.host}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
|
||||
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveOther();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherHost")}
|
||||
placeholder="git.example.com"
|
||||
value={other.host}
|
||||
onChange={(e) => setOther({ ...other, host: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherUser")}
|
||||
placeholder={t("gitIdentity.otherUser")}
|
||||
autoComplete="off"
|
||||
value={other.user}
|
||||
onChange={(e) => setOther({ ...other, user: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={BTN}
|
||||
disabled={busy || !other.host.trim() || !other.user.trim()}
|
||||
>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* first, and a member (S3) adds an HTTPS login of their own while the host's
|
||||
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||
* other host under "Other SSH hosts" — so the sections never mix.
|
||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||
* the sections never mix; a named key
|
||||
* is made beside the default ones, and a second AWS account is saved under
|
||||
* its own name with the one key it uses.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -21,6 +24,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
get: vi.fn(),
|
||||
generateKey: vi.fn(),
|
||||
removeKey: vi.fn(),
|
||||
removeKeyById: vi.fn(),
|
||||
setTerminalSsh: vi.fn(),
|
||||
setSshUser: vi.fn(),
|
||||
removeSshUser: vi.fn(),
|
||||
@@ -44,6 +48,10 @@ const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
/** Opens one tab of the SSH keys. */
|
||||
const tab = async (name: string | RegExp) =>
|
||||
userEvent.click(await screen.findByRole("tab", { name }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -97,6 +105,7 @@ describe("GitIdentitySettings", () => {
|
||||
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await tab("AWS CodeCommit");
|
||||
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
||||
@@ -116,11 +125,14 @@ describe("GitIdentitySettings", () => {
|
||||
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
|
||||
render(<GitIdentitySettings />);
|
||||
const gh = await screen.findByRole("region", { name: /GitHub/ });
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
|
||||
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
|
||||
// Each section explains only its own host.
|
||||
// One tab at a time: the other host's section is not on screen.
|
||||
expect(screen.queryByRole("region", { name: "AWS CodeCommit" })).toBeNull();
|
||||
expect(within(gh).queryByText(/IAM/)).toBeNull();
|
||||
await tab("AWS CodeCommit");
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(screen.queryByRole("region", { name: /GitHub/ })).toBeNull();
|
||||
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
|
||||
expect(within(aws).queryByText(/GitHub/)).toBeNull();
|
||||
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Delete" }));
|
||||
@@ -143,7 +155,7 @@ describe("GitIdentitySettings", () => {
|
||||
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||
expect(await within(aws).findByText("eu-west-1 · APKA1")).toBeInTheDocument();
|
||||
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
||||
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
|
||||
// A CodeCommit host never shows up again under "other hosts".
|
||||
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||
@@ -157,7 +169,7 @@ describe("GitIdentitySettings", () => {
|
||||
view({ ssh: KEY, sshUsers: [{ host: "git.example.com", user: "deploy" }] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByText("Other SSH hosts (advanced)"));
|
||||
await tab("Other hosts");
|
||||
await userEvent.type(screen.getByLabelText("Host"), "ssh://git.example.com/x");
|
||||
await userEvent.type(screen.getByLabelText("User name"), "deploy");
|
||||
const form = screen.getByLabelText("Host").closest("form")!;
|
||||
@@ -166,7 +178,7 @@ describe("GitIdentitySettings", () => {
|
||||
host: "git.example.com",
|
||||
user: "deploy",
|
||||
});
|
||||
expect(await screen.findByText("deploy@git.example.com")).toBeInTheDocument();
|
||||
expect(await screen.findByText("deploy@git.example.com · Any key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns while the commit author is unset and saves it", async () => {
|
||||
@@ -194,4 +206,69 @@ describe("GitIdentitySettings", () => {
|
||||
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
|
||||
});
|
||||
|
||||
it("makes a named key and saves a second AWS account under its name with that key", async () => {
|
||||
const RSA = {
|
||||
id: "id_rsa",
|
||||
type: "rsa" as const,
|
||||
name: null,
|
||||
publicKey: "ssh-rsa A x",
|
||||
fingerprint: "SHA256:r",
|
||||
};
|
||||
const PROD = {
|
||||
id: "id_rsa_aws-prod",
|
||||
type: "rsa" as const,
|
||||
name: "aws-prod",
|
||||
publicKey: "ssh-rsa B prod",
|
||||
fingerprint: "SHA256:p",
|
||||
};
|
||||
const H = "git-codecommit.ap-southeast-1.amazonaws.com";
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||
view({ sshKeys: [RSA, PROD], sshUsers: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [RSA, PROD],
|
||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
// Only an RSA key: the AWS tab opens first.
|
||||
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
||||
await tab("More keys");
|
||||
let more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
||||
|
||||
await tab("AWS CodeCommit");
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
||||
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
user: "APKA2",
|
||||
alias: "aws-prod",
|
||||
key: "id_rsa_aws-prod",
|
||||
});
|
||||
expect(
|
||||
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
|
||||
).toBeInTheDocument();
|
||||
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
||||
|
||||
await tab("More keys (1)");
|
||||
more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @file MemberTmuxCard.tsx
|
||||
* @description The Administration page's Server-tab 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"));
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,7 @@ export const i18nReady = i18n
|
||||
"splash",
|
||||
"lanes",
|
||||
"users",
|
||||
"admin",
|
||||
"onboarding",
|
||||
"artifacts",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Administration",
|
||||
"description": "Accounts and the server AgentHub runs on.",
|
||||
"tabs": {
|
||||
"users": "Users",
|
||||
"server": "Server"
|
||||
},
|
||||
"server": {
|
||||
"title": "Server",
|
||||
"uptime": "up {{v}}",
|
||||
"uptimeDays": "{{d}} d {{h}} h",
|
||||
"uptimeHours": "{{h}} h {{m}} min",
|
||||
"cpu": "CPU",
|
||||
"cores": "{{n}} cores · load {{load}}",
|
||||
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
|
||||
"memory": "Memory",
|
||||
"swap": "Swap",
|
||||
"homesDisk": "Homes disk",
|
||||
"homesDiskHelp": "The disk that holds the users' home folders.",
|
||||
"free": "free",
|
||||
"usageTitle": "Usage by user",
|
||||
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
|
||||
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
|
||||
"who": "User",
|
||||
"processes": "Processes",
|
||||
"home": "Home",
|
||||
"sampling": "Sampling…",
|
||||
"hostGroup": "Host account (AgentHub and other host processes)",
|
||||
"systemGroup": "System (root and other accounts)",
|
||||
"disks": "Disks",
|
||||
"mount": "Mount",
|
||||
"device": "Device",
|
||||
"used": "Used",
|
||||
"homesBadge": "homes",
|
||||
"gpus": "GPUs"
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,10 @@
|
||||
"shareDone": "Shared to the Hub",
|
||||
"unshareDone": "Removed from the Hub",
|
||||
"open": "Open",
|
||||
"copyLink": "Copy link",
|
||||
"preview": "Preview",
|
||||
"download": "Download",
|
||||
"noPreview": "This file type cannot be shown here — download it to open it.",
|
||||
"install": "Install",
|
||||
"installs": "{{count}} installs",
|
||||
"installed": "Installed to {{path}}",
|
||||
|
||||
@@ -577,7 +577,7 @@
|
||||
"fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
|
||||
"fx.pasted": "Pasted.",
|
||||
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
||||
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `sudo agenthub server install-system` once.",
|
||||
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
|
||||
"fx.conflictTitle": "Already there",
|
||||
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
||||
"fx.conflictAll": "Do this for every remaining file",
|
||||
@@ -591,6 +591,17 @@
|
||||
"fx.purge": "Delete forever",
|
||||
"fx.restored": "Restored.",
|
||||
"fx.trashEmpty": "The Trash is empty.",
|
||||
"fx.trashOf": "{{name}}'s Trash",
|
||||
"fx.myTrash": "My Trash",
|
||||
"fx.trashAt": "Trash ({{name}})",
|
||||
"fx.trashFoldersHint": "Each folder is one user's Trash — open it to restore or delete forever.",
|
||||
"fx.trashFolderEmpty": "Empty",
|
||||
"fx.openTrash": "Open Trash",
|
||||
"fx.purged": "Deleted forever.",
|
||||
"fx.emptyTrashTitle": "Empty the Trash?",
|
||||
"fx.emptyTrashBody": "All {{count}} items in {{where}} will be deleted forever. This cannot be undone.",
|
||||
"fx.purgeTitle": "Delete forever?",
|
||||
"fx.purgeBody": "“{{name}}” in {{where}} will be deleted forever. This cannot be undone.",
|
||||
"changes.openExplorer": "Open File Explorer — manage, upload and download files",
|
||||
"changes.openInExplorer": "Open in File Explorer",
|
||||
"fx.places": "Workspaces",
|
||||
@@ -620,7 +631,7 @@
|
||||
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
|
||||
"fx.myHome": "Home",
|
||||
"fx.userHomes": "Users' homes",
|
||||
"fx.readOnlyHome": "Another user's home — view and download only",
|
||||
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
|
||||
"fx.readOnly": "Read-only",
|
||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now",
|
||||
"users": "Users",
|
||||
"admin": "Administration",
|
||||
"files": "Files"
|
||||
}
|
||||
|
||||
@@ -937,7 +937,24 @@
|
||||
"otherHost": "Host",
|
||||
"otherUser": "User name",
|
||||
"otherSaved": "SSH user saved for {{host}}",
|
||||
"privateNote": "Only the public key is ever shown; the private key never leaves the server."
|
||||
"privateNote": "Only the public key is ever shown; the private key never leaves the server.",
|
||||
"anyKey": "Any key",
|
||||
"defaultEd": "ED25519 (default)",
|
||||
"defaultRsa": "RSA (default)",
|
||||
"loginKey": "Key",
|
||||
"alias": "Account name",
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "More SSH keys",
|
||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
||||
"newKey": "New key",
|
||||
"keyName": "Key name",
|
||||
"keyNamePlaceholder": "e.g. aws-prod",
|
||||
"keyType": "Key type",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "More keys",
|
||||
"tabOther": "Other hosts"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -204,5 +204,40 @@
|
||||
"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",
|
||||
"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 `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."
|
||||
}
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Open home in Files",
|
||||
"copyUsername": "Copy username",
|
||||
"copyEmail": "Copy email"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Quản trị",
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
|
||||
"tabs": {
|
||||
"users": "Người dùng",
|
||||
"server": "Máy chủ"
|
||||
},
|
||||
"server": {
|
||||
"title": "Máy chủ",
|
||||
"uptime": "đã chạy {{v}}",
|
||||
"uptimeDays": "{{d}} ngày {{h}} giờ",
|
||||
"uptimeHours": "{{h}} giờ {{m}} phút",
|
||||
"cpu": "CPU",
|
||||
"cores": "{{n}} lõi · tải {{load}}",
|
||||
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
|
||||
"memory": "Bộ nhớ",
|
||||
"swap": "Swap",
|
||||
"homesDisk": "Ổ chứa thư mục người dùng",
|
||||
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
|
||||
"free": "trống",
|
||||
"usageTitle": "Mức sử dụng theo người dùng",
|
||||
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
|
||||
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
|
||||
"who": "Người dùng",
|
||||
"processes": "Tiến trình",
|
||||
"home": "Thư mục home",
|
||||
"sampling": "Đang lấy mẫu…",
|
||||
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
|
||||
"systemGroup": "Hệ thống (root và tài khoản khác)",
|
||||
"disks": "Ổ đĩa",
|
||||
"mount": "Điểm gắn",
|
||||
"device": "Thiết bị",
|
||||
"used": "Đã dùng",
|
||||
"homesBadge": "home",
|
||||
"gpus": "GPU"
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,10 @@
|
||||
"shareDone": "Đã chia sẻ lên Hub",
|
||||
"unshareDone": "Đã gỡ khỏi Hub",
|
||||
"open": "Mở",
|
||||
"copyLink": "Sao chép link",
|
||||
"preview": "Xem",
|
||||
"download": "Tải về",
|
||||
"noPreview": "Không xem trước được loại file này — hãy tải về để mở.",
|
||||
"install": "Cài",
|
||||
"installs": "{{count}} lượt cài",
|
||||
"installed": "Đã cài vào {{path}}",
|
||||
|
||||
@@ -577,7 +577,7 @@
|
||||
"fx.cutDone": "Đã cắt {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
|
||||
"fx.pasted": "Đã dán.",
|
||||
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
||||
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `sudo agenthub server install-system` một lần.",
|
||||
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
|
||||
"fx.conflictTitle": "Đã có sẵn",
|
||||
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
||||
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
||||
@@ -591,6 +591,17 @@
|
||||
"fx.purge": "Xoá vĩnh viễn",
|
||||
"fx.restored": "Đã khôi phục.",
|
||||
"fx.trashEmpty": "Thùng rác trống.",
|
||||
"fx.trashOf": "Thùng rác của {{name}}",
|
||||
"fx.myTrash": "Thùng rác của tôi",
|
||||
"fx.trashAt": "Thùng rác ({{name}})",
|
||||
"fx.trashFoldersHint": "Mỗi thư mục là Thùng rác của một user — mở ra để khôi phục hoặc xoá vĩnh viễn.",
|
||||
"fx.trashFolderEmpty": "Trống",
|
||||
"fx.openTrash": "Mở Thùng rác",
|
||||
"fx.purged": "Đã xoá vĩnh viễn.",
|
||||
"fx.emptyTrashTitle": "Dọn sạch Thùng rác?",
|
||||
"fx.emptyTrashBody": "Toàn bộ {{count}} mục trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
|
||||
"fx.purgeTitle": "Xoá vĩnh viễn?",
|
||||
"fx.purgeBody": "“{{name}}” trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
|
||||
"changes.openExplorer": "Mở Quản lý file — quản lý, tải lên và tải về file",
|
||||
"changes.openInExplorer": "Mở trong Quản lý file",
|
||||
"fx.places": "Workspace",
|
||||
@@ -620,7 +631,7 @@
|
||||
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
|
||||
"fx.myHome": "Thư mục home",
|
||||
"fx.userHomes": "Home của các user",
|
||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
|
||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
|
||||
"fx.readOnly": "Chỉ đọc",
|
||||
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
||||
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"sidebar": "Thanh bên",
|
||||
"now": "bây giờ",
|
||||
"users": "Người dùng",
|
||||
"admin": "Quản trị",
|
||||
"files": "Quản lý file"
|
||||
}
|
||||
|
||||
@@ -937,7 +937,24 @@
|
||||
"otherHost": "Host",
|
||||
"otherUser": "Tên user",
|
||||
"otherSaved": "Đã lưu SSH user cho {{host}}",
|
||||
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server."
|
||||
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server.",
|
||||
"anyKey": "Mọi key",
|
||||
"defaultEd": "ED25519 (mặc định)",
|
||||
"defaultRsa": "RSA (mặc định)",
|
||||
"loginKey": "Key",
|
||||
"alias": "Tên tài khoản",
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "Thêm SSH key",
|
||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
||||
"newKey": "Key mới",
|
||||
"keyName": "Tên key",
|
||||
"keyNamePlaceholder": "vd aws-prod",
|
||||
"keyType": "Loại key",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "Key khác",
|
||||
"tabOther": "Host khác"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -204,5 +204,40 @@
|
||||
"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",
|
||||
"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 `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."
|
||||
}
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Mở home trong Files",
|
||||
"copyUsername": "Sao chép username",
|
||||
"copyEmail": "Sao chép email"
|
||||
}
|
||||
}
|
||||
|
||||
+137
-15
@@ -544,6 +544,71 @@ export type ServerUserUsage = AuthUser & {
|
||||
logins?: number;
|
||||
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 account's (or the host's / other accounts') live share of the machine (`GET /api/host`). */
|
||||
export interface HostUsageGroup {
|
||||
/** `user:<id>`, `host` (the host account outside any workspace) or `system`. */
|
||||
key: string;
|
||||
kind: "user" | "host" | "system";
|
||||
userId?: number;
|
||||
username?: string;
|
||||
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
||||
cpu: number;
|
||||
/** Resident memory, bytes (shared pages count in every process). */
|
||||
memory: number;
|
||||
memoryPercent: number;
|
||||
processes: number;
|
||||
/** Busiest processes, by the kernel's short name only. */
|
||||
top: { pid: number; name: string; cpu: number; memory: number }[];
|
||||
}
|
||||
/** `GET /api/host` — hardware, load, disks and per-user usage. */
|
||||
export interface HostStats {
|
||||
at: string;
|
||||
hostname: string;
|
||||
os: { platform: string; release: string; distro: string | null; arch: string };
|
||||
uptime: number;
|
||||
cpu: { model: string | null; cores: number; load: number[] };
|
||||
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
|
||||
disks: {
|
||||
device: string;
|
||||
mount: string;
|
||||
type: string;
|
||||
kind: "HDD" | "SSD" | null;
|
||||
total: number;
|
||||
used: number;
|
||||
free: number;
|
||||
}[];
|
||||
/** The mount holding the users' homes, or null. */
|
||||
homesMount: string | null;
|
||||
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
|
||||
/** null off Linux. */
|
||||
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
|
||||
}
|
||||
|
||||
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
|
||||
export type UserStorage = {
|
||||
bytes: number;
|
||||
@@ -817,6 +882,11 @@ export interface FsTrashItem {
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
deletedAt: string;
|
||||
/** A home's recycle bin only: which of the user's trash folders holds it. */
|
||||
store?: "home" | "host";
|
||||
/** The merged view only (`allTrash`): the home it belongs to, and its user. */
|
||||
home?: string;
|
||||
owner?: string;
|
||||
}
|
||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||
@@ -888,6 +958,10 @@ export const api = {
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
},
|
||||
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
|
||||
host: {
|
||||
stats: () => request<HostStats>("/host"),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
|
||||
@@ -915,6 +989,24 @@ export const api = {
|
||||
method: "POST",
|
||||
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: (id: number) => request<UserDetail>(`/users/${id}`),
|
||||
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
|
||||
@@ -2595,15 +2687,22 @@ export const api = {
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||
restore: (t: FsPlace, id: string) =>
|
||||
/** Every Trash the caller may open, merged (an admin: every user's). */
|
||||
allTrash: () =>
|
||||
request<{
|
||||
items: FsTrashItem[];
|
||||
errors?: { home: string; owner: string; message: string }[];
|
||||
keepDays?: number;
|
||||
}>("/homes/trash"),
|
||||
restore: (t: FsPlace, id: string, store?: FsTrashItem["store"]) =>
|
||||
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
body: JSON.stringify(store ? { id, store } : { id }),
|
||||
}),
|
||||
purge: (t: FsPlace, id: string | "all") =>
|
||||
purge: (t: FsPlace, id: string | "all", store?: FsTrashItem["store"]) =>
|
||||
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
body: JSON.stringify(store ? { id, store } : { id }),
|
||||
}),
|
||||
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
|
||||
zipUrl: (t: FsPlace, paths: string[]) => {
|
||||
@@ -2665,22 +2764,30 @@ export const api = {
|
||||
* Make the SSH key; `replace` swaps an existing one for a new pair.
|
||||
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
|
||||
*/
|
||||
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
|
||||
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519", name?: string) =>
|
||||
request<GitIdentity>("/me/git/ssh-key", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace, type }),
|
||||
body: JSON.stringify(name ? { replace, type, name } : { replace, type }),
|
||||
}),
|
||||
/** DELETE /api/me/git/ssh-key — the key of `type`, or every key. */
|
||||
/** DELETE /api/me/git/ssh-key — the default key of `type`, or every key. */
|
||||
removeKey: (type?: "ed25519" | "rsa") =>
|
||||
request<GitIdentity>(`/me/git/ssh-key${type ? `?type=${type}` : ""}`, { method: "DELETE" }),
|
||||
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
||||
removeKeyById: (id: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||
setTerminalSsh: (on: boolean) =>
|
||||
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
||||
/** PUT /api/me/git/ssh-user — the SSH login name for a host (CodeCommit: the SSH key ID). */
|
||||
setSshUser: (body: { host: string; user: string }) =>
|
||||
/**
|
||||
* PUT /api/me/git/ssh-user — an SSH login (CodeCommit: the SSH key ID as
|
||||
* the user): for the host itself, or with `alias` one more account on it
|
||||
* (`ssh://<alias>/…`); `key` = the only key it uses.
|
||||
*/
|
||||
setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) =>
|
||||
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
|
||||
removeSshUser: (host: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(host)}`, { method: "DELETE" }),
|
||||
/** DELETE a login by its host (the host's own rule) or its alias. */
|
||||
removeSshUser: (from: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
@@ -4211,14 +4318,29 @@ export interface GitIdentity {
|
||||
/** Whether a plain `git` in the account's terminal uses the key (`~/.gitconfig` core.sshCommand); `foreign` = the user set their own. */
|
||||
terminal?: "on" | "off" | "foreign";
|
||||
} | null;
|
||||
/** Every key the account holds (ED25519 first); absent from an older server. */
|
||||
sshKeys?: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string }[];
|
||||
/**
|
||||
* Every key the account holds: the default ED25519 and RSA first, then the
|
||||
* named ones (`name`; `id` = its file name); absent from an older server.
|
||||
*/
|
||||
sshKeys?: {
|
||||
id?: string;
|
||||
type?: "ed25519" | "rsa";
|
||||
name?: string | null;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
}[];
|
||||
/** The terminal switch (shared by both keys); null without a key. */
|
||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||
/** SSH login name per host (`url.<…>.insteadOf` in `~/.gitconfig`); absent from an older server. */
|
||||
sshUsers?: { host: string; user: string }[];
|
||||
/**
|
||||
* SSH logins (`url.<…>.insteadOf` in `~/.gitconfig`): `alias` null = the
|
||||
* host's own (`ssh://<host>/`), else `ssh://<alias>/`; `key` = the one key
|
||||
* it uses, null = every key. Absent from an older server.
|
||||
*/
|
||||
sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[];
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
/** The suggested email while none is set: `<username>@smartgift.vn`. */
|
||||
defaultEmail?: string | null;
|
||||
https: {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file Administration.tsx
|
||||
* @description The Administration page (`/admin`, a sidebar item; `/users`
|
||||
* redirects here), server mode only and only for an admin or analyst: a tab
|
||||
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the
|
||||
* accounts, the account pool and the audit log, `Users.tsx`) and **Server**
|
||||
* (the machine's hardware and each user's share of it, `ServerPanel`).
|
||||
* Local mode and a developer are sent home, like the Users page always did.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ServerPanel } from "../components/admin/ServerPanel";
|
||||
import { Users } from "./Users";
|
||||
|
||||
interface AdminTab {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export function Administration() {
|
||||
const { t } = useTranslation("admin");
|
||||
const auth = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
const tabs: AdminTab[] = [
|
||||
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
|
||||
{ id: "server", label: t("tabs.server"), Icon: Server },
|
||||
];
|
||||
const first = tabs[0]!;
|
||||
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<ShieldCheck className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageTabs
|
||||
ariaLabel={t("title")}
|
||||
activeId={active.id}
|
||||
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
|
||||
tabs={tabs}
|
||||
>
|
||||
{active.id === "server" ? (
|
||||
<ServerPanel isAdmin={me.role === "admin"} />
|
||||
) : (
|
||||
<Users embedded />
|
||||
)}
|
||||
</PageTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,11 @@
|
||||
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
|
||||
* installs off for everyone. Previews of a file are
|
||||
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
|
||||
* shared HTML file carries runs on the dashboard's origin.
|
||||
* shared HTML file carries runs on the dashboard's origin. Right-clicking a
|
||||
* card offers the same actions as its buttons, plus copying a page's link.
|
||||
* Preview is offered only for what a browser shows inside that sandbox (text,
|
||||
* images, HTML, audio/video); an Office file, a zip or a PDF is downloaded —
|
||||
* the sandbox blocks both a download and the PDF viewer, so it would be blank.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -43,6 +47,8 @@ import { eventBus } from "../lib/eventBus";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import {
|
||||
FileUploadPanel,
|
||||
PluginAddPanel,
|
||||
@@ -60,6 +66,9 @@ function fmtBytes(n: number | null): string {
|
||||
}
|
||||
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
||||
// What the sandboxed preview can show: the server's typed list minus PDF.
|
||||
const PREVIEWABLE =
|
||||
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i;
|
||||
|
||||
export function Artifacts() {
|
||||
const { t } = useTranslation("artifacts");
|
||||
@@ -70,6 +79,7 @@ export function Artifacts() {
|
||||
const [items, setItems] = useState<Artifact[] | null>(null);
|
||||
const [adding, setAdding] = useState<ArtifactKind | null>(null);
|
||||
const [addMenu, setAddMenu] = useState(false);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,6 +112,23 @@ export function Artifacts() {
|
||||
[load]
|
||||
);
|
||||
|
||||
// The Add menu closes on Escape or a click anywhere outside it.
|
||||
useEffect(() => {
|
||||
if (!addMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!addMenuRef.current?.contains(e.target as Node)) setAddMenu(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setAddMenu(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [addMenu]);
|
||||
|
||||
const server = !!settings?.serverMode;
|
||||
|
||||
const share = async (a: Artifact) => {
|
||||
@@ -183,11 +210,13 @@ export function Artifacts() {
|
||||
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="relative" ref={addMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
data-testid="artifacts-add"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addMenu}
|
||||
onClick={() => setAddMenu((v) => !v)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -325,12 +354,22 @@ function ArtifactCard({
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const Icon = KIND_ICON[a.kind];
|
||||
const menu = useContextMenu<null>();
|
||||
const pick = (fn: () => void) => () => {
|
||||
menu.close();
|
||||
fn();
|
||||
};
|
||||
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
|
||||
const canPreview =
|
||||
a.files.length > 0 &&
|
||||
(a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath)));
|
||||
const btn =
|
||||
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
return (
|
||||
<li
|
||||
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
|
||||
data-testid={`artifact-${a.id}`}
|
||||
onContextMenu={(e) => menu.open(e, null)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
|
||||
@@ -363,12 +402,8 @@ function ArtifactCard({
|
||||
{t("open")}
|
||||
</a>
|
||||
)}
|
||||
{(a.kind === "file" || a.kind === "skill") && a.files.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
onClick={() => onPreview(a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""))}
|
||||
>
|
||||
{canPreview && (
|
||||
<button type="button" className={btn} onClick={() => onPreview(previewPath)}>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("preview")}
|
||||
</button>
|
||||
@@ -410,6 +445,47 @@ function ArtifactCard({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{menu.menu && (
|
||||
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="artifact-menu">
|
||||
{a.kind === "page" && a.url && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => window.open(a.url!, "_blank", "noopener,noreferrer"))}>
|
||||
{t("open")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => void copyText(a.url!))}>{t("copyLink")}</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{canPreview && (
|
||||
<MenuItem onSelect={pick(() => onPreview(previewPath))}>{t("preview")}</MenuItem>
|
||||
)}
|
||||
{a.kind === "file" && a.files.length > 0 && (
|
||||
<MenuItem
|
||||
onSelect={pick(() => {
|
||||
window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true);
|
||||
})}
|
||||
>
|
||||
{t("download")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{(a.kind === "skill" || a.kind === "plugin") && (
|
||||
<MenuItem disabled={!canInstall} onSelect={pick(onInstall)}>
|
||||
{t("install")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{a.canChange && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{server && (
|
||||
<MenuItem onSelect={pick(onShare)}>{t(a.shared ? "unshare" : "share")}</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
|
||||
<MenuItem danger onSelect={pick(onDelete)}>
|
||||
{t("delete")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -550,6 +626,7 @@ function PreviewDialog({
|
||||
path: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [current, setCurrent] = useState(path);
|
||||
const url = api.artifacts.rawUrl(item.id, current);
|
||||
return (
|
||||
@@ -576,7 +653,18 @@ function PreviewDialog({
|
||||
</ul>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 rounded border border-border bg-white">
|
||||
{IMAGE.test(current) && !/\.svg$/i.test(current) ? (
|
||||
{!PREVIEWABLE.test(current) ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-surface-1 p-6 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("noPreview")}</p>
|
||||
<a
|
||||
href={api.artifacts.rawUrl(item.id, current, true)}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("download")}
|
||||
</a>
|
||||
</div>
|
||||
) : IMAGE.test(current) && !/\.svg$/i.test(current) ? (
|
||||
<img src={url} alt={current} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
// No allow-* tokens: a shared page renders, nothing in it runs.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Users.tsx
|
||||
* @description The Users page (`/users`, a sidebar item), server mode only and
|
||||
* @description The Users tab of the Administration page (`/admin`, which
|
||||
* `/users` redirects to — `Administration.tsx`), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
@@ -9,16 +10,19 @@
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||
* 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`). (Each member's own tmux server — one Linux user per
|
||||
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* 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; right-clicking a row offers
|
||||
* Edit, Open home (in /files), and copying the username or email.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -49,11 +53,13 @@ import { StatCard } from "../components/StatCard";
|
||||
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
||||
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
export function Users() {
|
||||
export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const { t } = useTranslation("users");
|
||||
const auth = useAuth();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
@@ -64,6 +70,8 @@ export function Users() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
|
||||
const rowMenu = useContextMenu<ServerUserUsage>();
|
||||
const navigate = useNavigate();
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
|
||||
@@ -124,11 +132,16 @@ export function Users() {
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
{/* Under Administration's tab bar the tab already names the page. */}
|
||||
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,6 +241,7 @@ export function Users() {
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
onContextMenu={(e) => rowMenu.open(e, u)}
|
||||
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
@@ -295,6 +309,47 @@ export function Users() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rowMenu.menu &&
|
||||
(() => {
|
||||
const u = rowMenu.menu.item;
|
||||
const pick = (fn: () => void) => () => {
|
||||
rowMenu.close();
|
||||
fn();
|
||||
};
|
||||
return (
|
||||
<ContextMenu
|
||||
x={rowMenu.menu.x}
|
||||
y={rowMenu.menu.y}
|
||||
onClose={rowMenu.close}
|
||||
testId="users-row-menu"
|
||||
>
|
||||
{isAdmin && (
|
||||
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
|
||||
)}
|
||||
{isAdmin && !u.disabled && (
|
||||
<MenuItem
|
||||
onSelect={pick(() =>
|
||||
navigate(
|
||||
`/files?home=${encodeURIComponent(u.id === me.id ? "me" : u.username)}`
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("rowMenu.openHome")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isAdmin && <MenuSeparator />}
|
||||
<MenuItem onSelect={pick(() => void copyText(u.username))}>
|
||||
{t("rowMenu.copyUsername")}
|
||||
</MenuItem>
|
||||
{u.email && (
|
||||
<MenuItem onSelect={pick(() => void copyText(u.email!))}>
|
||||
{t("rowMenu.copyEmail")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
})()}
|
||||
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
|
||||
<section className="card">
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file Administration.test.tsx
|
||||
* @description The Administration page (`/admin`): local mode and a developer
|
||||
* are sent home; staff land on the Users tab; the Server tab shows the
|
||||
* machine's hardware, every disk, and each user's CPU/memory/home share from
|
||||
* `GET /api/host`, with a user's busiest processes on click.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { AuthState, HostStats } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
vi.mock("../../components/AuthGate", () => ({
|
||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||
}));
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const stats: HostStats = {
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
hostname: "box",
|
||||
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
|
||||
uptime: 90000,
|
||||
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
|
||||
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
|
||||
disks: [
|
||||
{
|
||||
device: "/dev/sdb2",
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
kind: "SSD",
|
||||
total: 200 * GB,
|
||||
used: 50 * GB,
|
||||
free: 150 * GB,
|
||||
},
|
||||
{
|
||||
device: "/dev/sda1",
|
||||
mount: "/data",
|
||||
type: "ext4",
|
||||
kind: "HDD",
|
||||
total: 500 * GB,
|
||||
used: 0,
|
||||
free: 500 * GB,
|
||||
},
|
||||
],
|
||||
homesMount: "/",
|
||||
gpus: [],
|
||||
usage: {
|
||||
cpu: 40,
|
||||
sampleMs: 1000,
|
||||
groups: [
|
||||
{
|
||||
key: "user:2",
|
||||
kind: "user",
|
||||
userId: 2,
|
||||
username: "mia",
|
||||
cpu: 25,
|
||||
memory: 2 * GB,
|
||||
memoryPercent: 12.5,
|
||||
processes: 3,
|
||||
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
|
||||
},
|
||||
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn() },
|
||||
users: {
|
||||
list: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
storage: vi.fn(),
|
||||
tmux: vi.fn(async () => ({ available: false, users: [] })),
|
||||
avatarUrl: () => null,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...api,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: { list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Administration } from "../Administration";
|
||||
|
||||
const renderAt = (url: string) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<Routes>
|
||||
<Route path="/admin" element={<Administration />} />
|
||||
<Route path="/" element={<div data-testid="home" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
api.host.stats.mockResolvedValue(stats);
|
||||
api.users.list.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
username: "boss",
|
||||
role: "admin",
|
||||
disabled: false,
|
||||
workspaces: 0,
|
||||
sessions: 0,
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
displayName: "Mia",
|
||||
disabled: false,
|
||||
workspaces: 1,
|
||||
sessions: 2,
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
api.users.audit.mockResolvedValue({ entries: [] });
|
||||
api.users.storage.mockResolvedValue({
|
||||
storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } },
|
||||
measuring: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Administration", () => {
|
||||
it("sends local mode and a developer home", async () => {
|
||||
authState = { mode: "local" };
|
||||
renderAt("/admin");
|
||||
expect(await screen.findByTestId("home")).toBeTruthy();
|
||||
authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
|
||||
renderAt("/admin");
|
||||
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("opens on the Users tab", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderAt("/admin");
|
||||
expect(await screen.findByTestId("users-table")).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: /users/i }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows hardware and each user's share on the Server tab", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderAt("/admin?tab=server");
|
||||
const table = await screen.findByTestId("server-usage-table");
|
||||
expect(await screen.findByText("box")).toBeTruthy();
|
||||
expect(screen.getByTestId("server-disks-table").textContent).toContain("HDD");
|
||||
const mia = await within(table).findByText("Mia");
|
||||
const row = mia.closest("tr")!;
|
||||
expect(row.textContent).toContain("25%");
|
||||
expect(row.textContent).toContain("(13%)");
|
||||
// 20 GB home on the 200 GB homes disk.
|
||||
expect(row.textContent).toContain("10%");
|
||||
expect(within(table).queryByText("claude")).toBeNull();
|
||||
fireEvent.click(mia.closest("button")!);
|
||||
expect(within(table).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,13 @@
|
||||
* a plugin is picked from the list its marketplace link answers (a link the
|
||||
* server cannot read opens the by-hand form), a skill is uploaded from one
|
||||
* Markdown file under the name the user types, or written in the dialog.
|
||||
* A card's right-click menu runs the same actions (Preview, Delete). A file
|
||||
* the sandbox cannot show (Office, zip, PDF) offers Download only, and the
|
||||
* Add menu closes on Escape or a click outside it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Artifacts } from "../Artifacts";
|
||||
@@ -81,7 +84,13 @@ beforeEach(() => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({}),
|
||||
item({ id: 2, kind: "skill", title: "deploy", source: "deploy", files: [{ path: "SKILL.md", size: 3 }] }),
|
||||
item({
|
||||
id: 2,
|
||||
kind: "skill",
|
||||
title: "deploy",
|
||||
source: "deploy",
|
||||
files: [{ path: "SKILL.md", size: 3 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(confirmDialog).mockReset();
|
||||
@@ -145,7 +154,9 @@ describe("Adding", () => {
|
||||
await addMenu(user, "Skill");
|
||||
const md = new File(["# Steps"], "notes.md", { type: "text/markdown" });
|
||||
await user.upload(screen.getByTestId("skill-files"), md);
|
||||
expect(await screen.findByTestId("skill-shape")).toHaveTextContent("becomes the skill's SKILL.md");
|
||||
expect(await screen.findByTestId("skill-shape")).toHaveTextContent(
|
||||
"becomes the skill's SKILL.md"
|
||||
);
|
||||
const name = screen.getByLabelText(/Skill name/);
|
||||
await waitFor(() => expect(name).toHaveValue("notes"));
|
||||
await user.clear(name);
|
||||
@@ -229,4 +240,42 @@ describe("Artifacts", () => {
|
||||
expect(frame.getAttribute("sandbox")).toBe("");
|
||||
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
|
||||
});
|
||||
|
||||
it("opens a card's actions on right-click", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("report.html");
|
||||
fireEvent.contextMenu(screen.getByTestId("artifact-1"));
|
||||
const menu = screen.getByTestId("artifact-menu");
|
||||
expect(within(menu).getByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Install" })).toBeNull();
|
||||
await user.click(within(menu).getByRole("menuitem", { name: "Preview" }));
|
||||
expect(screen.queryByTestId("artifact-menu")).toBeNull();
|
||||
expect((await screen.findByTestId("artifact-preview")).getAttribute("src")).toBe(
|
||||
"/api/artifacts/1/raw?path=report.html"
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no preview for a file the sandbox cannot show, only its download", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ title: "plan.docx", files: [{ path: "plan.docx", size: 10 }] })],
|
||||
});
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-1");
|
||||
expect(within(card).queryByRole("button", { name: "Preview" })).toBeNull();
|
||||
expect(within(card).getByRole("link", { name: "Download" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the Add menu on Escape and on a click outside it", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("deploy");
|
||||
await user.click(screen.getByTestId("artifacts-add"));
|
||||
expect(screen.getByRole("button", { name: "Skill" })).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
|
||||
await user.click(screen.getByTestId("artifacts-add"));
|
||||
await user.click(screen.getByText("report.html"));
|
||||
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
* with their own password typed (the confirm stays disabled until then), sees
|
||||
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
|
||||
* separate role/password/workspace/disable buttons any more), and sees the
|
||||
* display name and email under a username.
|
||||
* display name and email under a username; right-clicking a row opens that
|
||||
* user's home in Files (an admin) — an analyst's menu only copies.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import type { AuthState, ServerUserUsage } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
@@ -40,6 +41,8 @@ const users = vi.hoisted(() => ({
|
||||
setPassword: vi.fn(),
|
||||
assignWorkspace: 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) => {
|
||||
@@ -58,12 +61,17 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
|
||||
import { Users } from "../Users";
|
||||
|
||||
function FilesProbe() {
|
||||
return <div data-testid="files">{useLocation().search}</div>;
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/users"]}>
|
||||
<Routes>
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/" element={<div data-testid="home" />} />
|
||||
<Route path="/files" element={<FilesProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -190,4 +198,23 @@ describe("Users page", () => {
|
||||
expect(screen.getByText("@mia")).toBeTruthy();
|
||||
expect(screen.getByText("mia@x.vn")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a row's menu on right-click: Open home for an admin, copy only for an analyst", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderPage();
|
||||
fireEvent.contextMenu((await screen.findByText("mia")).closest("tr")!);
|
||||
const menu = screen.getByTestId("users-row-menu");
|
||||
expect(within(menu).getByRole("menuitem", { name: "Copy username" })).toBeTruthy();
|
||||
fireEvent.click(within(menu).getByRole("menuitem", { name: "Open home in Files" }));
|
||||
expect((await screen.findByTestId("files")).textContent).toBe("?home=mia");
|
||||
});
|
||||
|
||||
it("gives an analyst's row menu no admin actions", async () => {
|
||||
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
|
||||
renderPage();
|
||||
fireEvent.contextMenu((await screen.findByText("mia")).closest("tr")!);
|
||||
const menu = screen.getByTestId("users-row-menu");
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Open home in Files" })).toBeNull();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Edit" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.3 (rebuild client to match)
|
||||
UI build v2.8.7 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+35
-10
@@ -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/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), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), 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.
|
||||
|
||||
@@ -292,7 +292,7 @@ come `fromLane` another workspace the caller can see, of the same account
|
||||
(`403 EFOREIGN` otherwise). An upload is at most 500 MB (`413 ETOOBIG`, from
|
||||
`Content-Length` before a byte is stored), a zip at most 2 GB / 50,000 files.
|
||||
Under S3 a move/copy/delete needs the installed `write-as` to know
|
||||
`move`/`copy`: `409 EUPGRADE` until `sudo agenthub server install-system` is
|
||||
`move`/`copy`: `409 EUPGRADE` until `agenthub server install-system` is
|
||||
re-run.
|
||||
|
||||
#### File Explorer on a home
|
||||
@@ -311,7 +311,32 @@ the host account's home. Every dot entry at the TOP of a home (`.ssh`,
|
||||
refused for every read and write — also through a symlink, a download or a
|
||||
zip (`403 ESECRET`); dot files deeper down are ordinary. `:who` is `me`; an
|
||||
admin may also name another user — read-only (`403 EREADONLY` on every
|
||||
write), each read written to the audit log. A copy goes home ↔ workspace
|
||||
write), each read written to the audit log. The one exception is that user's
|
||||
**Trash**.
|
||||
|
||||
A home's Trash is its USER's one recycle bin, whatever place they deleted in:
|
||||
`GET …/trash` merges their home account's trash (a member's
|
||||
`~/.local/share/agenthub/trash/`) with their host trash
|
||||
(`<data dir>/trash/<user id>/`, what they deleted in a host-folder workspace)
|
||||
and tags each item `store: "home" | "host"`; `POST …/trash/restore` and
|
||||
`POST …/trash/purge` take `{id, store}` (`store` defaults to `home`;
|
||||
`{id: "all"}` empties every store). A host item whose workspace is gone
|
||||
answers `404 ENOFILE` (nowhere of that account to put it back). An admin may
|
||||
restore and purge in ANOTHER user's Trash too, each written to the audit log
|
||||
with the item id — so a member's deleted files can be restored or freed
|
||||
without waiting the 7 days. `GET /api/homes/trash` merges every Trash the
|
||||
caller may open into one list (`{items, errors, keepDays}`; an admin's is
|
||||
every user's, each other user's read audited): each item carries `home` (the
|
||||
`:who` its restore/purge goes to) and `owner`, and a user whose trash cannot
|
||||
be read is listed in `errors` rather than left out. The Files sidebar has ONE
|
||||
**Trash** entry: a user's opens their own Trash; an admin's opens on one
|
||||
folder per user (**My Trash** first, each counted from that merged list, an
|
||||
unreadable one showing its error), and opening a folder shows that user's
|
||||
Trash (path bar **Trash › user**, Up goes back to the folders). A trash that
|
||||
could not be read shows as an error rather than as empty, and deleting
|
||||
forever asks first.
|
||||
|
||||
A copy goes home ↔ workspace
|
||||
(`fromHome` on the workspace route, `fromLane` on the home one) when both are
|
||||
one Linux account; each side is checked under its own rule. A home is never a
|
||||
workspace. The **Files** page opens on `?home=me` by default.
|
||||
@@ -1042,15 +1067,15 @@ cannot collide on `index.lock`.
|
||||
#### Your git identity
|
||||
|
||||
```
|
||||
GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ type, publicKey, fingerprint }] (ed25519 first), sshTerminal, https: [{ protocol, host, username, hasToken }] }
|
||||
POST /api/me/git/ssh-key Make the SSH key of `type` — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS when that TYPE exists, without replace; the other type is kept — an account may hold one of each, and every ssh git offers both) → 201, same shape
|
||||
DELETE /api/me/git/ssh-key Delete the key of `?type=ed25519|rsa`, or every key without it (404 ENOTFOUND, 400 EBADKEYTYPE) — also removes our terminal `core.sshCommand`
|
||||
PUT /api/me/git/ssh-terminal Use the key for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (ours first, then `~/.ssh/id_*`); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
|
||||
PUT /api/me/git/ssh-user The SSH login name for one host — Body: { host, user } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` to the account's `~/.gitconfig` (replacing ours for that host), so a plain `ssh://<host>/…` URL logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`); 400 EBADHOST / EBADSSHUSER. Listed as `sshUsers: [{host, user}]` in GET /api/me/git
|
||||
DELETE /api/me/git/ssh-user/:host Drop that rule (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
|
||||
GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ id, type, name, publicKey, fingerprint }] (default ed25519, default rsa, then named keys by name; `name` null for a default key, `id` = its file name), sshTerminal, sshUsers: [{ host, user, alias, key }], https: [{ protocol, host, username, hasToken }], author: { name, email }, defaultEmail }
|
||||
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
|
||||
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
|
||||
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
|
||||
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key? } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. 400 EBADHOST / EBADSSHUSER
|
||||
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
|
||||
POST /api/me/git/https Save an https login — Body: { host, username, password }
|
||||
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
|
||||
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account
|
||||
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account. A new SSH key is labelled with that email, else `defaultEmail` (`<username>@smartgift.vn`; `AGENTHUB_EMAIL_DOMAIN` overrides the domain), also returned by GET /api/me/git as the email form's suggestion
|
||||
```
|
||||
|
||||
`GET` also answers `author: { name, email }` (null when unset). A member's
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| `agenthub uninstall [--yes]` | **Uninstall AgentHub** (`scripts/uninstall.js`; Settings → System has the same button). Without `--yes` it only lists what is there and exits 1. With it, in order: closes every workspace terminal, removes the systemd/launchd unit, stops every dashboard server, the status-area icon and workspace stacks, takes AgentHub's own entries out of `~/.claude/settings.json` (hooks, statusline; backup `settings.json.agenthub-uninstall.bak`), Codex `hooks.json` and Antigravity `hooks.json`, deletes the AI Proxy `$CODEX_HOME/<name>.config.toml` files, runs `claude plugin uninstall` for `agenthub@*` and every plugin of the `agenthub` marketplace and removes that marketplace (never a shared one), then deletes `~/.local/bin/agenthub`, `~/.config/agenthub/`, `~/.claude/.agenthub.json`, `~/.claude/agenthub-{proxies,routers,git-credentials,accounts,profiles}`, `agenthub-lanes/.state` and `.locks`, the plugin's cached copies and the data directory (database included). **Kept:** the AI tools' own conversations (`~/.claude/projects`, `~/.codex/sessions`, …), workspace worktrees and branches, anything in your repositories, config backups. A data directory that is the home, `CLAUDE_HOME`, a checkout or not recognisably AgentHub's is refused. A failing step is logged and the rest still run; log: `$TMPDIR/agenthub-uninstall-<uid>.log`. Needs no running server. Run inside a dashboard terminal, it closes that terminal last. |
|
||||
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
|
||||
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
|
||||
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
|
||||
| `agenthub server setup [--hostname name] [--admin name] [--no-system]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. On Linux it then installs **one Linux user per account** by default — runs `install-system` (one sudo; skipped when `/etc/agenthub/server.conf` already exists) and `users provision --all`; if that fails, server mode stays on and the error says how to finish. `--no-system` (and any non-Linux machine) keeps every pane on the one host account, as before; then restart the dashboard yourself. See [SERVER-MODE.md](SERVER-MODE.md) |
|
||||
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
||||
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+70
-22
@@ -15,8 +15,9 @@ Local mode does not change at all. The login code stays dormant until
|
||||
> **What server mode separates, and what it does not.** In the dashboard and
|
||||
> its API, each developer sees only their own workspaces and the sessions,
|
||||
> prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)).
|
||||
> Until [one Linux user per account](#one-linux-user-per-account) is installed
|
||||
> (`agenthub server install-system`), every terminal pane runs as the one Linux
|
||||
> On Linux, `agenthub server setup` installs [one Linux user per account](#one-linux-user-per-account)
|
||||
> by default. Without it (`--no-system`, a machine that is not Linux, or an
|
||||
> install that failed), every terminal pane runs as the one Linux
|
||||
> account that runs the dashboard. An agent in a pane can then read every
|
||||
> user's folders, transcripts and the database straight from disk. Without it,
|
||||
> use server mode only with people you trust.
|
||||
@@ -27,12 +28,14 @@ On the machine that will host the dashboard, logged in as the account that
|
||||
runs it:
|
||||
|
||||
```bash
|
||||
# 1. Turn server mode on, allow the public name, and create the first admin
|
||||
# 1. Turn server mode on, allow the public name, create the first admin and,
|
||||
# on Linux, install one Linux user per account (one sudo; it moves the
|
||||
# dashboard to the system unit agenthub.service, which picks the mode up)
|
||||
agenthub server setup --hostname agenthub.example.com --admin boss
|
||||
# (asks for the admin's password twice; from a script: pipe it on stdin)
|
||||
|
||||
# 2. Restart the dashboard so it picks the mode up
|
||||
systemctl --user restart agenthub # or: agenthub start
|
||||
# 2. Log out and back in once, so your shell joins the agenthub groups
|
||||
# (with --no-system instead: systemctl --user restart agenthub)
|
||||
|
||||
# 3. Add the other people
|
||||
agenthub users add alice # developer
|
||||
@@ -42,7 +45,14 @@ agenthub users add carol --role analyst # reads everyone, works in their o
|
||||
`setup` writes `AGENTHUB_MODE=server` and `DASHBOARD_ALLOWED_HOSTS` into
|
||||
AgentHub's settings file (`~/.config/agenthub/settings.env`). The real
|
||||
environment and a checkout's `.env` still take priority over that file. Run
|
||||
setup again whenever you need to: it creates an admin only when none exists.
|
||||
setup again whenever you need to: it creates an admin only when none exists,
|
||||
runs `install-system` only while `/etc/agenthub/server.conf` is missing, and
|
||||
gives every user their Linux account (`users provision --all`, idempotent).
|
||||
The machine needs what [What a new machine needs](#what-a-new-machine-needs-before-install-system)
|
||||
lists first. If the install fails, server mode stays on with every pane on the
|
||||
host account, and the error says so: fix the cause and run
|
||||
`agenthub server install-system`, or choose that shared account on purpose
|
||||
with `agenthub server setup --no-system`.
|
||||
|
||||
## Put HTTPS in front of it
|
||||
|
||||
@@ -143,7 +153,21 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
|
||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
||||
sessions, and the tokens and $ it used in the last 7 and 30 days — the
|
||||
Users page in the sidebar shows it, with the audit log under it.
|
||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||
redirects there) shows it on its **Users** tab, with the audit log under it.
|
||||
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
||||
**Server** tab: the machine's hostname, OS, uptime, CPU model, cores and
|
||||
load, memory and swap, every real disk (HDD or SSD, how full; the one
|
||||
holding the users' homes is marked), any NVIDIA GPU, and each user's live
|
||||
share of it — CPU % of the whole machine (100 % = every core busy) and
|
||||
resident memory over a 1 s `/proc` sample, a process count, the busiest
|
||||
processes by their kernel name (never argv or env), and their home's size
|
||||
as a share of the homes disk. A process counts for a user when it runs as
|
||||
their Linux account (S3) or, for the host account, when it inherited a
|
||||
workspace's `AGENTHUB_LANE_ID` — so without S3 a user's share is only what
|
||||
runs in their workspaces' panes; the rest is **Host account** or
|
||||
**System**. Read-only (`server/lib/host-stats.js`); Linux only for the
|
||||
per-user part. The page re-reads every 5 s while shown.
|
||||
- `GET /api/users/storage` (admin/analyst) is the page's **Storage** column:
|
||||
how much disk each user's home takes (their Linux home under S3, their
|
||||
folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads
|
||||
@@ -544,9 +568,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) |
|
||||
| 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 |
|
||||
| `/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 |
|
||||
| `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 |
|
||||
| `/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 |
|
||||
@@ -562,7 +586,7 @@ What runs as the member, and how:
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one section per kind of host, each with only its own steps: **GitHub · GitLab · Gitea · Bitbucket** (ED25519) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), plus a collapsed **Other SSH hosts (advanced)**. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **More keys** (named keys for extra accounts) and **Other hosts** (advanced); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
@@ -682,9 +706,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
|
||||
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
|
||||
100% CPU. A member's server is still asked with `TMUX_BIN` (the one path
|
||||
sudoers allows), so after a tmux upgrade restart each member's tmux server
|
||||
once it is idle (`sudo kill <pid>` of `tmux -S /srv/agenthub/users/<name>/.agenthub/tmux/sock`).
|
||||
100% CPU.
|
||||
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
|
||||
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 `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`.
|
||||
- **Administration → Server → 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
|
||||
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
|
||||
@@ -746,15 +783,25 @@ itself, once.
|
||||
|
||||
### 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
|
||||
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, as the host account and **without** `sudo` in front (it asks
|
||||
for the sudo password itself; as root it refuses, and `sudo agenthub …` fails
|
||||
with `command not found` because sudo's `secure_path` has no `~/.local/bin`):
|
||||
|
||||
```bash
|
||||
sudo agenthub server install-system
|
||||
agenthub server install-system
|
||||
```
|
||||
|
||||
A member server that a newer tmux already reached before `tmux-member` was
|
||||
installed can stay wedged: every call on that socket — `tmux-member`'s too —
|
||||
prints `server exited unexpectedly`. If Administration → Server → Members' tmux shows it with
|
||||
no panes, stop that one server (its pid is the `ah-<name>` process running
|
||||
`tmux -S <USERS_ROOT>/<name>/.agenthub/tmux/sock`) with `sudo kill -9 <pid>`;
|
||||
the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`.
|
||||
|
||||
Until then the File Explorer's move, copy and delete in a member's folder
|
||||
answer `409 EUPGRADE` (listing, upload, new folder/file and zip work).
|
||||
The same re-run installs `agenthub-provision delete`, behind the Users page's
|
||||
@@ -789,9 +836,10 @@ again under the same name gets a NEW, empty Linux account and home.
|
||||
4. AgentHub installed for the host account, as the plugin or a checkout
|
||||
(`npm run setup && npm run build`), and running once
|
||||
(`agenthub start`).
|
||||
5. Server mode on, with the first admin:
|
||||
`agenthub server setup --hostname agenthub.example.com --admin boss`
|
||||
(see [Set it up](#set-it-up)).
|
||||
5. Server mode itself is NOT a step before this: `agenthub server setup`
|
||||
(see [Set it up](#set-it-up)) turns it on and then runs `install-system`
|
||||
and `users provision --all` below itself. Run the Install steps by hand only
|
||||
after a `setup --no-system` or a failed install, or to repeat one.
|
||||
6. The AI tools members will use, installed for the host account (the AI
|
||||
Tools page or by hand).
|
||||
7. The HTTPS reverse proxy (see [Put HTTPS in front of it](#put-https-in-front-of-it)).
|
||||
@@ -821,7 +869,7 @@ again changes nothing that is already in place):
|
||||
host account's);
|
||||
3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`,
|
||||
`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/`;
|
||||
5. copies node to `/opt/agenthub/tools/node/<version>` (linked from
|
||||
`tools/bin/node`) and this install's `scripts/` and `server/lib/` to
|
||||
@@ -846,8 +894,8 @@ again changes nothing that is already in place):
|
||||
|
||||
What a new machine still needs by hand after it:
|
||||
|
||||
1. `agenthub users provision --all`: users created before S3 was on (the
|
||||
admin from `server setup` is one) have no Linux account yet.
|
||||
1. `agenthub users provision --all`: users created before S3 was on have no
|
||||
Linux account yet (`server setup` already ran this for you).
|
||||
2. Log out and back in once, so your own shell joins `agenthub` and
|
||||
`agenthub-proc` (the unit gets them on its own).
|
||||
3. `chmod o-rwx ~` for the host account if the installer warned: the database
|
||||
|
||||
@@ -19,7 +19,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
||||
## Rules (binding, same spirit as CLAUDE.md)
|
||||
|
||||
- **What is shared is a SNAPSHOT, never the owner's live file.** Sharing a `file`/`skill` copies it (size-capped, regular files only, no symlinks followed) into `<data dir>/artifacts/<id>/`, written once like `session_snapshots`. Viewers read only that copy, so no route ever reads another user's home for them. Re-sharing makes a new snapshot.
|
||||
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a `page` is only a link out, accepted only as `https://claude.ai/…`.
|
||||
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a type that sandbox cannot show (Office files, zip, PDF — the sandbox blocks both a download and the PDF viewer) gets Download only, never a blank Preview. A `page` is only a link out, accepted only as `https://claude.ai/…`.
|
||||
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
|
||||
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
|
||||
- **Visibility follows ownership:** a developer sees their own artifacts plus shared ones; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
|
||||
|
||||
@@ -112,7 +112,7 @@ Branch: `server-s3`. Commits happen only when the user asks.
|
||||
- Verify: an e2e against a fake upstream (header swapped, streaming kept, a dead pane's token refused, another member's profile refused).
|
||||
|
||||
### P2. Host setup (sudo; on this machine with the agh-test users)
|
||||
- [x] `sudo agenthub server install-system` is idempotent, has `--uninstall`, and does the following:
|
||||
- [x] `agenthub server install-system` is idempotent, has `--uninstall`, and does the following:
|
||||
- creates groups `agenthub` and `agenthub-members`;
|
||||
- creates `/srv/agenthub/users` (0751) and `/opt/agenthub/{tools,app,libexec,sbin}`;
|
||||
- writes the sudoers file and checks it with `visudo -c`;
|
||||
|
||||
+323
-4
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.7.7
|
||||
version: 2.8.5
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -3952,6 +3952,10 @@ components:
|
||||
enum:
|
||||
- true
|
||||
example: true
|
||||
archived:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Only with `?archive=1`: the id of the session the pane held, now archived, or null when the pane had no known session (a shell).'
|
||||
LaneWorktreeCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
@@ -4819,6 +4823,10 @@ components:
|
||||
email:
|
||||
type: string
|
||||
nullable: true
|
||||
avatarAt:
|
||||
type: string
|
||||
nullable: true
|
||||
description: When the avatar was last set (null = none)
|
||||
createdAt:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -11205,6 +11213,14 @@ paths:
|
||||
format: uuid
|
||||
description: Run id (RunHandle.id).
|
||||
example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c
|
||||
- name: archive
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- '1'
|
||||
description: '`1` (the UI''s Stop) also archives the session the pane held, so it leaves its workspace''s lists. Omitted (the UI''s Sleep, the CLI) the session stays listed, to be resumed later.'
|
||||
responses:
|
||||
'200':
|
||||
description: Kill issued (or run already terminated).
|
||||
@@ -14282,6 +14298,94 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/avatar:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: getMyAvatar
|
||||
summary: The account's own picture (nosniff, sandbox CSP)
|
||||
responses:
|
||||
'200':
|
||||
description: The image (PNG, JPEG, GIF or WebP)
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOAVATAR
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
put:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: setMyAvatar
|
||||
summary: Set the account's picture from the raw image bytes (PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
'200':
|
||||
description: Set
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MyAccount'
|
||||
'400':
|
||||
description: EBADIMAGE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: EAUTH — no login cookie (a pane token is refused)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'413':
|
||||
description: ETOOBIG
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
delete:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: removeMyAvatar
|
||||
summary: Remove the account's picture
|
||||
responses:
|
||||
'200':
|
||||
description: Removed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MyAccount'
|
||||
'401':
|
||||
description: EAUTH — no login cookie (a pane token is refused)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users:
|
||||
get:
|
||||
tags:
|
||||
@@ -14391,6 +14495,118 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/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':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/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:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '{user}'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/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':
|
||||
description: EREAUTH / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/avatar:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: getUserAvatar
|
||||
summary: One account's picture, for the sidebar's owner groups (admin/analyst)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: The image (PNG, JPEG, GIF or WebP)
|
||||
'403':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOAVATAR
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}:
|
||||
get:
|
||||
tags:
|
||||
@@ -14741,6 +14957,10 @@ paths:
|
||||
type: number
|
||||
spend7d:
|
||||
type: number
|
||||
tokens5h:
|
||||
type: integer
|
||||
tokens7d:
|
||||
type: integer
|
||||
quota:
|
||||
type: object
|
||||
nullable: true
|
||||
@@ -14770,6 +14990,10 @@ paths:
|
||||
type: number
|
||||
spend7d:
|
||||
type: number
|
||||
tokens5h:
|
||||
type: integer
|
||||
tokens7d:
|
||||
type: integer
|
||||
quota:
|
||||
type: object
|
||||
nullable: true
|
||||
@@ -15761,12 +15985,12 @@ paths:
|
||||
operationId: getGitIdentity
|
||||
responses:
|
||||
'200':
|
||||
description: '{account, writable, ssh: {publicKey, fingerprint} | null, https: [...], author: {name, email}}'
|
||||
description: '{account, writable, ssh: {type, publicKey, fingerprint, terminal} | null, https: [...], author: {name, email}}'
|
||||
/api/me/git/ssh-key:
|
||||
post:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Make (or with `replace`, replace) the SSH key
|
||||
summary: Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it
|
||||
operationId: generateSshKey
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -15778,17 +16002,112 @@ paths:
|
||||
properties:
|
||||
replace:
|
||||
type: boolean
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- ed25519
|
||||
- rsa
|
||||
name:
|
||||
type: string
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,31}$
|
||||
responses:
|
||||
'201':
|
||||
description: The identity.
|
||||
'400':
|
||||
description: EBADKEYNAME
|
||||
'409':
|
||||
description: EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS
|
||||
delete:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Delete the SSH key
|
||||
summary: Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key
|
||||
operationId: deleteSshKey
|
||||
parameters:
|
||||
- name: type
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- ed25519
|
||||
- rsa
|
||||
- name: id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The identity.
|
||||
/api/me/git/ssh-terminal:
|
||||
put:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Use the SSH key for git in the account's own terminal (core.sshCommand)
|
||||
operationId: setTerminalSsh
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- 'on'
|
||||
properties:
|
||||
'on':
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: The identity; `ssh.terminal` is on, off or foreign.
|
||||
'409':
|
||||
description: EFOREIGNSSH — ~/.gitconfig already sets its own core.sshCommand.
|
||||
/api/me/git/ssh-user:
|
||||
put:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: 'An SSH login: url.<ssh://user@host/>.insteadOf ssh://<alias or host>/, optionally tied to one key'
|
||||
operationId: setSshUser
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- host
|
||||
- user
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
user:
|
||||
type: string
|
||||
alias:
|
||||
type: string
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,62}$
|
||||
key:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'The identity; `sshUsers: [{host, user, alias, key}]`.'
|
||||
'400':
|
||||
description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY
|
||||
/api/me/git/ssh-user/{from}:
|
||||
delete:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Drop the SSH login of a host or alias (and its key link)
|
||||
operationId: deleteSshUser
|
||||
parameters:
|
||||
- name: from
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The identity.
|
||||
'404':
|
||||
description: ENOTFOUND
|
||||
/api/me/git/https:
|
||||
post:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.3",
|
||||
"version": "2.8.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.3",
|
||||
"version": "2.8.7",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.3",
|
||||
"version": "2.8.7",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# /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/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/claude-code/managed-settings.json the member hooks (never over a foreign file)
|
||||
# /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/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/tmux-member.sh" /opt/agenthub/libexec/tmux-member
|
||||
|
||||
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
|
||||
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."
|
||||
[ -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"
|
||||
for a in start stop restart enable disable; do
|
||||
[ "$a" = start ] || [ "$a" = stop ] || [ "$a" = restart ] && extra=" --no-block" || extra=""
|
||||
|
||||
Executable
+34
@@ -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" "$@"
|
||||
@@ -6,7 +6,7 @@
|
||||
* a systemd `--user` unit everywhere else (`systemd-service.js`, which reports
|
||||
* itself unavailable off Linux). Both export the same API — and so does the
|
||||
* third, `systemd-system-service.js`, chosen while the server-mode SYSTEM unit
|
||||
* `sudo agenthub server install-system` wrote exists (pure fs check).
|
||||
* `agenthub server install-system` wrote exists (pure fs check).
|
||||
* Require THIS module, never one of the three directly.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @file systemd-system-service.js
|
||||
* @description Server mode with one Linux user per account (S3): the dashboard
|
||||
* runs as a SYSTEM unit, `/etc/systemd/system/agenthub.service`, written once
|
||||
* by `sudo agenthub server install-system` (scripts/server/install-system.sh).
|
||||
* by `agenthub server install-system` (scripts/server/install-system.sh).
|
||||
* The unit runs as the host account with group `agenthub` and
|
||||
* CAP_DAC_READ_SEARCH — the two things a `systemd --user` unit cannot give.
|
||||
*
|
||||
@@ -162,7 +162,7 @@ function installUserService(opts) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Removing the system unit is root's: `sudo agenthub server install-system --uninstall`. */
|
||||
/** Removing the system unit is root's: `agenthub server install-system --uninstall`. */
|
||||
function uninstallUserService() {
|
||||
return "foreign";
|
||||
}
|
||||
|
||||
@@ -42,19 +42,21 @@ function request(method, p, body, headers = {}) {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
text: await r.text(),
|
||||
})).then((r) => {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(r.text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return { ...r, body: json };
|
||||
});
|
||||
})
|
||||
.then(async (r) => ({
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
text: await r.text(),
|
||||
}))
|
||||
.then((r) => {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(r.text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return { ...r, body: json };
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
@@ -63,18 +65,23 @@ before(async () => {
|
||||
fs.writeFileSync(path.join(ws, "main.js"), "code");
|
||||
const skill = path.join(ws, ".claude", "skills", "deploy");
|
||||
fs.mkdirSync(path.join(skill, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(skill, "SKILL.md"), "---\nname: deploy\ndescription: Ship it\n---\nDo it.\n");
|
||||
fs.writeFileSync(
|
||||
path.join(skill, "SKILL.md"),
|
||||
"---\nname: deploy\ndescription: Ship it\n---\nDo it.\n"
|
||||
);
|
||||
fs.writeFileSync(path.join(skill, "scripts", "run.sh"), "echo hi\n");
|
||||
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
|
||||
lane = lanesLib.createLane({ cwd: ws, title: "ws" });
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)").run(
|
||||
ws,
|
||||
lane.id
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)"
|
||||
).run(ws, lane.id);
|
||||
const ev = db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES ('s1', 'PostToolUse', ?, ?)"
|
||||
);
|
||||
ev.run("Write", JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } }));
|
||||
ev.run(
|
||||
"Write",
|
||||
JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } })
|
||||
);
|
||||
ev.run("Edit", JSON.stringify({ tool_input: { file_path: path.join(ws, "main.js") } }));
|
||||
ev.run("Write", JSON.stringify({ tool_input: { file_path: "/etc/passwd.md" } }));
|
||||
server = http.createServer(createApp());
|
||||
@@ -125,7 +132,10 @@ describe("files", () => {
|
||||
});
|
||||
|
||||
it("pins a snapshot and serves it sandboxed, never a path outside it", async () => {
|
||||
const r = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "out/report.html" });
|
||||
const r = await request("POST", "/api/artifacts/file", {
|
||||
laneId: lane.id,
|
||||
path: "out/report.html",
|
||||
});
|
||||
assert.equal(r.status, 201, r.text);
|
||||
const id = r.body.item.id;
|
||||
// The live file changes; the artifact does not.
|
||||
@@ -138,7 +148,10 @@ describe("files", () => {
|
||||
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
|
||||
assert.equal(other.status, 404);
|
||||
|
||||
const escape = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "../t.db" });
|
||||
const escape = await request("POST", "/api/artifacts/file", {
|
||||
laneId: lane.id,
|
||||
path: "../t.db",
|
||||
});
|
||||
assert.equal(escape.status, 400);
|
||||
|
||||
const del = await request("DELETE", `/api/artifacts/${id}`);
|
||||
@@ -154,10 +167,7 @@ describe("skills", () => {
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
|
||||
assert.equal(r.status, 201, r.text);
|
||||
assert.equal(r.body.item.description, "Ship it");
|
||||
assert.deepEqual(
|
||||
r.body.item.files.map((f) => f.path).sort(),
|
||||
["SKILL.md", "scripts/run.sh"]
|
||||
);
|
||||
assert.deepEqual(r.body.item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
|
||||
const id = r.body.item.id;
|
||||
|
||||
const first = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||
@@ -240,7 +250,12 @@ function upload(p, files, fields = {}) {
|
||||
|
||||
const snapText = (item, file = "SKILL.md") =>
|
||||
fs.readFileSync(
|
||||
path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot, file),
|
||||
path.join(
|
||||
process.env.DASHBOARD_DATA_DIR,
|
||||
"artifacts",
|
||||
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot,
|
||||
file
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
@@ -263,7 +278,10 @@ describe("plugins from a marketplace link", () => {
|
||||
n("https://git.example.vn/Org/skills/src/branch/main/"),
|
||||
"https://git.example.vn/Org/skills"
|
||||
);
|
||||
assert.equal(n("https://git.example.vn/Org/skills.git"), "https://git.example.vn/Org/skills.git");
|
||||
assert.equal(
|
||||
n("https://git.example.vn/Org/skills.git"),
|
||||
"https://git.example.vn/Org/skills.git"
|
||||
);
|
||||
assert.throws(() => n("file:///etc"), { code: "EBADMARKET" });
|
||||
assert.throws(() => n("http://x/y.git"), { code: "EBADMARKET" });
|
||||
});
|
||||
@@ -281,7 +299,10 @@ describe("plugins from a marketplace link", () => {
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(asked, "https://git.example.vn/Org/skills.git");
|
||||
assert.equal(r.body.name, "sg");
|
||||
assert.deepEqual(r.body.plugins.map((p) => p.key), ["sg-dev@sg", "sg-common@sg"]);
|
||||
assert.deepEqual(
|
||||
r.body.plugins.map((p) => p.key),
|
||||
["sg-dev@sg", "sg-common@sg"]
|
||||
);
|
||||
|
||||
const add = (title) =>
|
||||
request("POST", "/api/artifacts/plugin", { key: "sg-dev@sg", market: r.body.market, title });
|
||||
@@ -312,14 +333,21 @@ describe("plugins from a marketplace link", () => {
|
||||
|
||||
describe("skills from files or written", () => {
|
||||
it("makes one Markdown file the SKILL.md, named by the user, with a frontmatter", async () => {
|
||||
const r = await upload("/api/artifacts/skill/upload", [["Notes cho đội.md", "# Do this\nStep 1\n"]], {
|
||||
name: "Hướng dẫn deploy",
|
||||
description: "Use when deploying",
|
||||
});
|
||||
const r = await upload(
|
||||
"/api/artifacts/skill/upload",
|
||||
[["Notes cho đội.md", "# Do this\nStep 1\n"]],
|
||||
{
|
||||
name: "Hướng dẫn deploy",
|
||||
description: "Use when deploying",
|
||||
}
|
||||
);
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const [item] = r.body.items;
|
||||
assert.equal(item.source, "Huong-dan-deploy");
|
||||
assert.deepEqual(item.files.map((f) => f.path), ["SKILL.md"]);
|
||||
assert.deepEqual(
|
||||
item.files.map((f) => f.path),
|
||||
["SKILL.md"]
|
||||
);
|
||||
const text = snapText(item);
|
||||
assert.match(text, /^---\nname: Huong-dan-deploy\ndescription: Use when deploying\n---\n/);
|
||||
assert.match(text, /# Do this/);
|
||||
@@ -364,15 +392,23 @@ describe("skills from files or written", () => {
|
||||
["b/SKILL.md", "no frontmatter"],
|
||||
]);
|
||||
assert.equal(r.body.error.code, "ENODESCRIPTION");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n, before);
|
||||
const secret = await upload(
|
||||
"/api/artifacts/skill/upload",
|
||||
[["s/SKILL.md", "---\ndescription: S\n---\n"], ["s/.env", "K=1"]]
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n,
|
||||
before
|
||||
);
|
||||
const secret = await upload("/api/artifacts/skill/upload", [
|
||||
["s/SKILL.md", "---\ndescription: S\n---\n"],
|
||||
["s/.env", "K=1"],
|
||||
]);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], { description: "d" });
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], {
|
||||
description: "d",
|
||||
});
|
||||
assert.equal(out.body.error.code, "EBADPATH");
|
||||
const none = await upload("/api/artifacts/skill/upload", [["a.png", "x"], ["b.png", "y"]]);
|
||||
const none = await upload("/api/artifacts/skill/upload", [
|
||||
["a.png", "x"],
|
||||
["b.png", "y"],
|
||||
]);
|
||||
assert.equal(none.body.error.code, "ENOSKILLMD");
|
||||
});
|
||||
|
||||
@@ -383,13 +419,22 @@ describe("skills from files or written", () => {
|
||||
content: "Summarise.",
|
||||
});
|
||||
assert.equal(r.status, 201, r.text);
|
||||
assert.match(snapText(r.body.item), /^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./);
|
||||
const bad = await request("POST", "/api/artifacts/skill/new", { name: "!!!", description: "d", content: "c" });
|
||||
assert.match(
|
||||
snapText(r.body.item),
|
||||
/^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./
|
||||
);
|
||||
const bad = await request("POST", "/api/artifacts/skill/new", {
|
||||
name: "!!!",
|
||||
description: "d",
|
||||
content: "c",
|
||||
});
|
||||
assert.equal(bad.body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
|
||||
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], { title: "Report" });
|
||||
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
|
||||
title: "Report",
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
assert.equal(r.body.item.kind, "file");
|
||||
assert.equal(r.body.item.title, "Report");
|
||||
@@ -408,13 +453,25 @@ describe("sharing and visibility", () => {
|
||||
});
|
||||
|
||||
it("shows a server-mode developer their own rows and shared ones only", () => {
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')").run();
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)").run();
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')").run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')"
|
||||
).run();
|
||||
const dev = { method: "GET", user: { id: 7, role: "developer", username: "d" } };
|
||||
const titles = artifacts.list(dev).map((i) => i.title).sort();
|
||||
const titles = artifacts
|
||||
.list(dev)
|
||||
.map((i) => i.title)
|
||||
.sort();
|
||||
assert.deepEqual(titles, ["a", "b"]);
|
||||
assert.deepEqual(artifacts.list(dev, { scope: "hub" }).map((i) => i.title), ["b"]);
|
||||
assert.deepEqual(
|
||||
artifacts.list(dev, { scope: "hub" }).map((i) => i.title),
|
||||
["b"]
|
||||
);
|
||||
const hidden = db.prepare("SELECT id FROM artifacts WHERE title = 'c'").get().id;
|
||||
assert.equal(artifacts.canSeeId(dev, hidden), false);
|
||||
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
|
||||
@@ -431,3 +488,67 @@ describe("sharing and visibility", () => {
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search, frontmatter and marketplace limits", () => {
|
||||
it("finds a title holding `_` or `%` literally, and only that one", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'my_skill', 'https://claude.ai/u1')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'myXskill', 'https://claude.ai/u2')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, url) VALUES ('page', '100% done', 'https://claude.ai/u3')"
|
||||
).run();
|
||||
assert.deepEqual(
|
||||
artifacts.list(null, { q: "my_skill" }).map((i) => i.title),
|
||||
["my_skill"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
artifacts.list(null, { q: "100%" }).map((i) => i.title),
|
||||
["100% done"]
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a multi-line description whole, and quotes one YAML would misread", () => {
|
||||
const src =
|
||||
"---\nname: x\ndescription: >\n old line one\n old line two\nallowed-tools: Read\n---\nbody\n";
|
||||
assert.equal(
|
||||
artifacts.withFrontmatter(src, "x", "new desc"),
|
||||
"---\nname: x\ndescription: new desc\nallowed-tools: Read\n---\nbody\n"
|
||||
);
|
||||
assert.match(
|
||||
artifacts.withFrontmatter("body", "x", "- Use for reports"),
|
||||
/^description: "- Use for reports"$/m
|
||||
);
|
||||
assert.match(
|
||||
artifacts.withFrontmatter("body", "x", "Use for reports"),
|
||||
/^description: Use for reports$/m
|
||||
);
|
||||
});
|
||||
|
||||
it("stops reading a marketplace.json link past 1 MB", async () => {
|
||||
const real = globalThis.fetch;
|
||||
let pulled = 0;
|
||||
globalThis.fetch = async () => {
|
||||
const body = new ReadableStream({
|
||||
pull(c) {
|
||||
pulled += 1;
|
||||
if (pulled > 100) return c.close();
|
||||
c.enqueue(new Uint8Array(64 * 1024));
|
||||
},
|
||||
});
|
||||
const res = new Response(body);
|
||||
Object.defineProperty(res, "url", { value: "https://x.example/m.json" });
|
||||
return res;
|
||||
};
|
||||
try {
|
||||
await assert.rejects(artifacts.inspectMarket("https://x.example/m.json"), {
|
||||
code: "ETOOBIG",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = real;
|
||||
}
|
||||
assert.ok(pulled < 30, `read ${pulled} chunks`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* `POST /api/lanes/clone` (URL allowlist, name/parent checks, a clone that
|
||||
* becomes a workspace, the SSH key persisted into the clone, 409
|
||||
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
|
||||
* only the public half returned, 0600 on disk), and the member (S3) path of
|
||||
* only the public half returned, 0600 on disk; named keys beside the default
|
||||
* ones, SSH logins per alias tied to one key, which a clone then offers
|
||||
* alone), and the member (S3) path of
|
||||
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches
|
||||
* git's argv, and that a login typed without "remember" leaves the member's
|
||||
* store as it was.
|
||||
@@ -246,6 +248,8 @@ describe("/api/me/git", () => {
|
||||
const made = await request("POST", "/api/me/git/ssh-key", {});
|
||||
assert.equal(made.status, 201);
|
||||
assert.match(made.body.ssh.publicKey, /^ssh-ed25519 /);
|
||||
// No commit author yet: the key is labelled with the default email.
|
||||
assert.match(made.body.ssh.publicKey, /@smartgift\.vn$/);
|
||||
assert.match(made.body.ssh.fingerprint, /^SHA256:/);
|
||||
assert.ok(!JSON.stringify(made.body).includes("PRIVATE"));
|
||||
const key = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519");
|
||||
@@ -311,6 +315,7 @@ describe("/api/me/git", () => {
|
||||
it("reads and sets the commit author, refusing a bad one", async () => {
|
||||
const r0 = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(r0.body.author, { name: null, email: null });
|
||||
assert.equal(r0.body.defaultEmail, `${os.userInfo().username}@smartgift.vn`);
|
||||
const bad = await request("PUT", "/api/me/git/author", { name: "a\nb", email: "x@y" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADAUTHOR");
|
||||
@@ -318,6 +323,10 @@ describe("/api/me/git", () => {
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.body.author, { name: "Vi", email: "vi@example.com" });
|
||||
assert.match(fs.readFileSync(path.join(ROOT, ".gitconfig"), "utf8"), /email = vi@example.com/);
|
||||
// A key made now is labelled with the author's email.
|
||||
const key = await request("POST", "/api/me/git/ssh-key", { replace: true });
|
||||
assert.match(key.body.ssh.publicKey, / vi@example\.com$/);
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
});
|
||||
|
||||
it("saves an https login without ever returning the token", async () => {
|
||||
@@ -404,7 +413,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||
user: "APKAEXAMPLE1",
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE1" }]);
|
||||
assert.deepEqual(r.body.sshUsers, [
|
||||
{ host: HOST, user: "APKAEXAMPLE1", alias: null, key: null },
|
||||
]);
|
||||
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE1@${HOST}/v1/repos/x`);
|
||||
assert.equal(getUrl("ssh://other.test/x"), "ssh://other.test/x");
|
||||
// A second save replaces, never stacks a second rewrite.
|
||||
@@ -412,7 +423,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||
host: HOST,
|
||||
user: "APKAEXAMPLE2",
|
||||
});
|
||||
assert.deepEqual(again.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE2" }]);
|
||||
assert.deepEqual(again.body.sshUsers, [
|
||||
{ host: HOST, user: "APKAEXAMPLE2", alias: null, key: null },
|
||||
]);
|
||||
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE2@${HOST}/v1/repos/x`);
|
||||
const del = await request("DELETE", `/api/me/git/ssh-user/${HOST}`);
|
||||
assert.deepEqual(del.body.sshUsers, []);
|
||||
@@ -434,7 +447,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||
assert.equal(bad.body.error.code, "EBADSSHUSER");
|
||||
// Ours lists only our shape; a rule the user wrote for another host is kept.
|
||||
const r = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(r.body.sshUsers, [{ host: "mine.test", user: "me" }]);
|
||||
assert.deepEqual(r.body.sshUsers, [
|
||||
{ host: "mine.test", user: "me", alias: null, key: null },
|
||||
]);
|
||||
assert.equal((await request("DELETE", "/api/me/git/ssh-user/other.test")).status, 404);
|
||||
assert.equal(getUrl("ssh://mine.test/x"), "ssh://me@mine.test/x");
|
||||
} finally {
|
||||
@@ -443,6 +458,122 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("several accounts: named keys and SSH logins per alias", () => {
|
||||
const getUrl = (url) =>
|
||||
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
|
||||
const HOST = "git-codecommit.us-east-1.amazonaws.com";
|
||||
|
||||
it("keeps named keys beside the default ones, and deletes one by id", async () => {
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
const a = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "AWS-Prod" });
|
||||
assert.equal(a.status, 201, JSON.stringify(a.body));
|
||||
const b = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
|
||||
const d = await request("POST", "/api/me/git/ssh-key", {});
|
||||
assert.deepEqual(
|
||||
d.body.sshKeys.map((k) => [k.id, k.type, k.name]),
|
||||
[
|
||||
["id_ed25519", "ed25519", null],
|
||||
["id_rsa_aws-dev", "rsa", "aws-dev"],
|
||||
["id_rsa_aws-prod", "rsa", "aws-prod"],
|
||||
]
|
||||
);
|
||||
assert.notEqual(b.body.sshKeys[0].publicKey, a.body.sshKeys[0].publicKey);
|
||||
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
|
||||
assert.equal(fs.statSync(path.join(dir, "id_rsa_aws-prod")).mode & 0o777, 0o600);
|
||||
// One name per key, whatever its type; a bad name is refused.
|
||||
const dup = await request("POST", "/api/me/git/ssh-key", { name: "aws-dev" });
|
||||
assert.equal(dup.status, 409);
|
||||
const bad = await request("POST", "/api/me/git/ssh-key", { name: "../x" });
|
||||
assert.equal(bad.body.error.code, "EBADKEYNAME");
|
||||
const gone = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev");
|
||||
assert.deepEqual(
|
||||
gone.body.sshKeys.map((k) => k.id),
|
||||
["id_ed25519", "id_rsa_aws-prod"]
|
||||
);
|
||||
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev")).status, 404);
|
||||
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=../x")).status, 400);
|
||||
// The terminal command names every key once switched on, and follows them.
|
||||
await request("PUT", "/api/me/git/ssh-terminal", { on: true });
|
||||
const cmd = () =>
|
||||
execFileSync("git", ["config", "--global", "core.sshCommand"], { encoding: "utf8" });
|
||||
assert.match(cmd(), /id_rsa_aws-prod'/);
|
||||
const again = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
|
||||
assert.equal(again.body.sshTerminal, "on");
|
||||
assert.match(cmd(), /id_rsa_aws-dev'/);
|
||||
await request("PUT", "/api/me/git/ssh-terminal", { on: false });
|
||||
});
|
||||
|
||||
it("logs each alias in as its own user, and a clone of it uses that key alone", async () => {
|
||||
const prod = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: HOST,
|
||||
user: "APKAPROD",
|
||||
alias: "AWS-Prod",
|
||||
key: "id_rsa_aws-prod",
|
||||
});
|
||||
assert.equal(prod.status, 200, JSON.stringify(prod.body));
|
||||
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEV", alias: "aws-dev" });
|
||||
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEFAULT" });
|
||||
const rows = (await request("GET", "/api/me/git")).body.sshUsers;
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.alias, r.user, r.key]).sort(),
|
||||
[
|
||||
["aws-dev", "APKADEV", null],
|
||||
["aws-prod", "APKAPROD", "id_rsa_aws-prod"],
|
||||
[null, "APKADEFAULT", null],
|
||||
].sort()
|
||||
);
|
||||
assert.equal(getUrl("ssh://aws-prod/v1/repos/x"), `ssh://APKAPROD@${HOST}/v1/repos/x`);
|
||||
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), `ssh://APKADEV@${HOST}/v1/repos/x`);
|
||||
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKADEFAULT@${HOST}/v1/repos/x`);
|
||||
// Refused: an alias with a dot (it could shadow a real host), a key that is not there.
|
||||
const dot = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: HOST,
|
||||
user: "A",
|
||||
alias: "a.b",
|
||||
});
|
||||
assert.equal(dot.body.error.code, "EBADALIAS");
|
||||
const nokey = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: HOST,
|
||||
user: "A",
|
||||
alias: "x",
|
||||
key: "id_rsa_nope",
|
||||
});
|
||||
assert.equal(nokey.body.error.code, "ENOKEY");
|
||||
|
||||
// The test forge stands in for CodeCommit: a longer insteadOf wins.
|
||||
const bare = path.join(ROOT, "bare.git");
|
||||
execFileSync("git", [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
`url.file://${bare}.insteadOf`,
|
||||
"ssh://aws-prod/v1/repos/r",
|
||||
]);
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "ssh://aws-prod/v1/repos/r",
|
||||
parent: WORK,
|
||||
name: "via-alias",
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const cmd = execFileSync(
|
||||
"git",
|
||||
["-C", path.join(WORK, "via-alias"), "config", "core.sshCommand"],
|
||||
{ encoding: "utf8" }
|
||||
).trim();
|
||||
assert.match(cmd, /^ssh -o IdentityFile='.*\/id_rsa_aws-prod' -o IdentitiesOnly=yes$/);
|
||||
|
||||
// Deleting the key unties the login; deleting the login drops the rule.
|
||||
const unkeyed = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-prod");
|
||||
assert.equal(unkeyed.body.sshUsers.find((x) => x.alias === "aws-prod").key, null);
|
||||
for (const f of ["aws-prod", "aws-dev", HOST]) {
|
||||
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${f}`)).status, 200);
|
||||
}
|
||||
assert.deepEqual((await request("GET", "/api/me/git")).body.sshUsers, []);
|
||||
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), "ssh://aws-dev/v1/repos/x");
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("git-identity, member account (S3, stubbed)", () => {
|
||||
const runAs = require("../lib/run-as");
|
||||
const worktree = require("../lib/worktree");
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* (also through a symlink, a download or a zip) while a nested dotfile is
|
||||
* fine, delete/restore work with no workspace, a copy goes home ↔ workspace
|
||||
* under each side's own rule — and in server mode a developer never reaches
|
||||
* another user's home while an admin gets it read-only.
|
||||
* another user's home while an admin gets it read-only — except its Trash,
|
||||
* which the admin lists, restores from and purges as the member's own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -204,3 +205,143 @@ describe("homes in server mode", () => {
|
||||
assert.ok(homes.listHomes({ user: ann }).some((x) => x.id === "bob" && x.readOnly));
|
||||
});
|
||||
});
|
||||
|
||||
describe("an admin manages a member's Trash", () => {
|
||||
const auth = require("../lib/auth");
|
||||
const USERS = path.join(ROOT, "users");
|
||||
let cara;
|
||||
let dan;
|
||||
const call = (method, p, cookie, body) =>
|
||||
fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", cookie },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
const login = async (username, password) => {
|
||||
const r = await fetch(`${base}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return r.headers.get("set-cookie").split(";")[0];
|
||||
};
|
||||
before(async () => {
|
||||
fs.mkdirSync(path.join(USERS, "cara"), { recursive: true });
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||
await auth.createUser("cara", "cara-pass-1", "developer");
|
||||
await auth.createUser("dan", "dan-pass-1", "admin");
|
||||
cara = await login("cara", "cara-pass-1");
|
||||
dan = await login("dan", "dan-pass-1");
|
||||
});
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
});
|
||||
|
||||
it("lists, restores and purges the member's own trash — the rest stays read-only", async () => {
|
||||
const C = "/api/homes/me/fs";
|
||||
const D = "/api/homes/cara/fs";
|
||||
for (const n of ["old", "junk"]) {
|
||||
assert.equal((await call("POST", `${C}/mkdir`, cara, { path: n })).status, 201);
|
||||
}
|
||||
assert.equal((await call("POST", `${C}/delete`, cara, { paths: ["old", "junk"] })).status, 200);
|
||||
|
||||
// The admin sees CARA's trash, not their own.
|
||||
let items = (await call("GET", `${D}/trash`, dan)).body.items;
|
||||
assert.deepEqual(items.map((i) => i.name).sort(), ["junk", "old"]);
|
||||
assert.equal((await call("GET", "/api/homes/me/fs/trash", dan)).body?.items?.length ?? 0, 0);
|
||||
|
||||
// …and every user's in one list, each item tagged with its home and owner.
|
||||
const all = (await call("GET", "/api/homes/trash", dan)).body;
|
||||
assert.deepEqual(
|
||||
all.items.map((i) => [i.name, i.home, i.owner]).sort(),
|
||||
[
|
||||
["junk", "cara", "cara"],
|
||||
["old", "cara", "cara"],
|
||||
]
|
||||
);
|
||||
// A developer's merged view is only their own.
|
||||
assert.deepEqual(
|
||||
(await call("GET", "/api/homes/trash", cara)).body.items.map((i) => i.home),
|
||||
["me", "me"]
|
||||
);
|
||||
const old = items.find((i) => i.name === "old");
|
||||
const r = await call("POST", `${D}/trash/restore`, dan, { id: old.id });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.ok(fs.statSync(path.join(USERS, "cara", "old")).isDirectory());
|
||||
|
||||
assert.equal((await call("POST", `${D}/trash/purge`, dan, { id: "all" })).body.purged, 1);
|
||||
assert.equal((await call("GET", `${C}/trash`, cara)).body.items.length, 0);
|
||||
|
||||
// Everything else in another user's home is still refused.
|
||||
const w = await call("POST", `${D}/mkdir`, dan, { path: "nope" });
|
||||
assert.equal(w.status, 403);
|
||||
assert.equal(w.body.error.code, "EREADONLY");
|
||||
assert.equal((await call("POST", `${D}/delete`, dan, { paths: ["old"] })).status, 403);
|
||||
|
||||
// A developer still never reaches another user's trash.
|
||||
assert.equal((await call("GET", "/api/homes/dan/fs/trash", cara)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one recycle bin per user", () => {
|
||||
const fx = require("../lib/file-explorer");
|
||||
const runAs = require("../lib/run-as");
|
||||
const MEMBER = path.join(ROOT, "member-home");
|
||||
const put = (root, id, name, laneId) => {
|
||||
fs.mkdirSync(path.join(root, id, "item"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, id, "meta.json"),
|
||||
JSON.stringify({
|
||||
id,
|
||||
name,
|
||||
laneId,
|
||||
rel: name,
|
||||
isDir: true,
|
||||
deletedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
it("has one store when the home is the host's", async () => {
|
||||
const out = await fx.listUserTrash(HOME, "solo");
|
||||
assert.deepEqual(out.items, []);
|
||||
put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "solo"), "solo0001", "x", null);
|
||||
const items = (await fx.listUserTrash(HOME, "solo")).items;
|
||||
assert.deepEqual(
|
||||
items.map((i) => [i.name, i.store]),
|
||||
[["x", "home"]]
|
||||
);
|
||||
assert.equal(
|
||||
(await fx.purgeUserTrash(HOME, "solo", "host", "solo0001").catch((e) => e)).code,
|
||||
"EBADPATH"
|
||||
);
|
||||
});
|
||||
|
||||
it("merges a member's home trash with their host trash, each item tagged", async (t) => {
|
||||
fs.mkdirSync(MEMBER, { recursive: true });
|
||||
const acct = { name: "mem", home: MEMBER, uid: process.getuid() };
|
||||
const orig = runAs.accountForPath;
|
||||
t.after(() => {
|
||||
runAs.accountForPath = orig;
|
||||
});
|
||||
runAs.accountForPath = (p) => (String(p).startsWith(MEMBER) ? acct : null);
|
||||
put(path.join(MEMBER, ".local", "share", "agenthub", "trash"), "memhome01", "fromHome", null);
|
||||
put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77"), "memhost01", "fromHostWs", 5);
|
||||
const items = (await fx.listUserTrash(MEMBER, "77")).items;
|
||||
assert.deepEqual(items.map((i) => [i.name, i.store]).sort(), [
|
||||
["fromHome", "home"],
|
||||
["fromHostWs", "host"],
|
||||
]);
|
||||
// A host item whose workspace is gone has nowhere to go back to.
|
||||
const r = await fx
|
||||
.restoreUserTrash(MEMBER, "77", "host", "memhost01", () => null)
|
||||
.catch((e) => e);
|
||||
assert.equal(r.code, "ENOFILE");
|
||||
assert.equal((await fx.purgeUserTrash(MEMBER, "77", "host", "memhost01")).purged, 1);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77", "memhost01"))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @file host-stats.test.js
|
||||
* @description Tests for server/lib/host-stats.js — the Server tab's hardware
|
||||
* snapshot and per-user CPU/memory attribution (`GET /api/host`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hostStats, mountOf, __setDeps } = require("../lib/host-stats");
|
||||
|
||||
const linux = process.platform === "linux";
|
||||
|
||||
test("mountOf picks the longest mount point holding the path", () => {
|
||||
const list = [{ mount: "/" }, { mount: "/srv" }, { mount: "/srv/users" }];
|
||||
assert.equal(mountOf(list, "/srv/users/alice"), "/srv/users");
|
||||
assert.equal(mountOf(list, "/srv/usersx"), "/srv");
|
||||
assert.equal(mountOf(list, "/home/x"), "/");
|
||||
assert.equal(mountOf(list, null), null);
|
||||
});
|
||||
|
||||
test("hostStats reports hardware and attributes processes by uid", { skip: !linux }, async () => {
|
||||
const me = process.getuid();
|
||||
__setDeps({
|
||||
users: () => [
|
||||
{ id: 7, username: "zed" },
|
||||
{ id: 8, username: "idle" },
|
||||
],
|
||||
memberNameForUid: (uid) => (uid === me ? "zed" : null),
|
||||
laneOwner: () => null,
|
||||
homesRoot: () => "/",
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
assert.ok(r.cpu.cores > 0);
|
||||
assert.ok(r.memory.total > 0 && r.memory.used >= 0);
|
||||
assert.ok(Array.isArray(r.disks));
|
||||
const zed = r.usage.groups.find((g) => g.key === "user:7");
|
||||
assert.ok(zed.processes > 0, "this test's own process belongs to zed");
|
||||
assert.ok(zed.memory > 0);
|
||||
assert.ok(zed.top.every((t) => typeof t.name === "string" && !("argv" in t)));
|
||||
const idle = r.usage.groups.find((g) => g.key === "user:8");
|
||||
assert.deepEqual([idle.cpu, idle.memory, idle.processes], [0, 0, 0]);
|
||||
assert.equal(
|
||||
r.usage.groups.find((g) => g.key === "host"),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"a host process with a workspace's AGENTHUB_LANE_ID counts for its owner",
|
||||
{ skip: !linux },
|
||||
async () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const child = spawn("sleep", ["5"], { env: { ...process.env, AGENTHUB_LANE_ID: "4242" } });
|
||||
try {
|
||||
__setDeps({
|
||||
users: () => [{ id: 3, username: "own" }],
|
||||
memberNameForUid: () => null,
|
||||
laneOwner: (id) => (id === 4242 ? 3 : null),
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
const own = r.usage.groups.find((g) => g.key === "user:3");
|
||||
assert.ok(own.top.some((t) => t.pid === child.pid));
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -240,9 +240,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
|
||||
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`
|
||||
);
|
||||
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, "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(
|
||||
runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3],
|
||||
"/opt/agenthub/libexec/write-as"
|
||||
|
||||
@@ -187,6 +187,7 @@ describe("Linux accounts", () => {
|
||||
hostUid: 4242,
|
||||
usersRoot: "/srv/agenthub/users",
|
||||
port: 4820,
|
||||
tmuxInstalled: null,
|
||||
bins: {},
|
||||
});
|
||||
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"]) {
|
||||
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, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ const authRouter = require("./routes/auth");
|
||||
const meRouter = require("./routes/me");
|
||||
const accountsRouter = require("./routes/accounts");
|
||||
const usersRouter = require("./routes/users");
|
||||
const hostRouter = require("./routes/host");
|
||||
const { auditGuard } = require("./lib/audit");
|
||||
const { authGuard, isServerMode, hasAdmin } = require("./lib/auth");
|
||||
const { scopeGuard } = require("./lib/scope");
|
||||
@@ -179,6 +180,7 @@ function createApp() {
|
||||
app.use("/api/me", meRouter);
|
||||
app.use("/api/accounts", accountsRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
app.use("/api/host", hostRouter);
|
||||
|
||||
app.use("/api/sessions", sessionsRouter);
|
||||
app.use("/api/agents", agentsRouter);
|
||||
|
||||
+177
-60
@@ -57,7 +57,8 @@ const KINDS = ["page", "file", "skill", "plugin"];
|
||||
const DOC_EXT =
|
||||
/\.(html?|md|markdown|mdx|pdf|png|jpe?g|gif|webp|svg|csv|tsv|json|ya?ml|txt|log|xlsx?|docx?|pptx?|ipynb|mp4|webm|mp3|wav|zip)$/i;
|
||||
const PAGE_URL_RE = /^https:\/\/claude\.ai\/[A-Za-z0-9._~\-/?#=&%]+$/;
|
||||
const PAGE_IN_TEXT_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+[A-Za-z0-9._~\-/?#=&%]*/;
|
||||
const PAGE_IN_TEXT_RE =
|
||||
/https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+[A-Za-z0-9._~\-/?#=&%]*/;
|
||||
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
||||
@@ -86,7 +87,8 @@ const canSee = (req, row) => {
|
||||
};
|
||||
|
||||
/** Only the owner changes a row — or an admin, or anyone in local mode. */
|
||||
const canChange = (req, row) => !req?.user || req.user.role === "admin" || row.owner_id === req.user.id;
|
||||
const canChange = (req, row) =>
|
||||
!req?.user || req.user.role === "admin" || row.owner_id === req.user.id;
|
||||
|
||||
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
|
||||
function canSeeId(req, id) {
|
||||
@@ -101,12 +103,18 @@ function load(req, id) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function view(row, req) {
|
||||
function view(row, req, owners = null) {
|
||||
if (!row) return null;
|
||||
const owner =
|
||||
row.owner_id == null
|
||||
? null
|
||||
: sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id);
|
||||
let owner = null;
|
||||
if (row.owner_id != null) {
|
||||
// A list passes one cache, so each owner is looked up once, not per row.
|
||||
if (owners?.has(row.owner_id)) owner = owners.get(row.owner_id);
|
||||
else {
|
||||
owner =
|
||||
sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id) || null;
|
||||
owners?.set(row.owner_id, owner);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
@@ -159,15 +167,22 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
params.push(String(sessionId));
|
||||
}
|
||||
if (q && String(q).trim()) {
|
||||
where.push("(a.title LIKE ? OR a.description LIKE ? OR a.source LIKE ?)");
|
||||
const like = `%${String(q).trim().replace(/[%_]/g, (c) => `\\${c}`)}%`;
|
||||
// `\` escapes `%`/`_` (and itself) only with an ESCAPE clause: SQLite
|
||||
// has no default escape character, so without it `my_skill` found nothing.
|
||||
where.push(
|
||||
"(a.title LIKE ? ESCAPE '\\' OR a.description LIKE ? ESCAPE '\\' OR a.source LIKE ? ESCAPE '\\')"
|
||||
);
|
||||
const like = `%${String(q)
|
||||
.trim()
|
||||
.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
||||
params.push(like, like, like);
|
||||
}
|
||||
const n = Math.min(Math.max(Number(limit) || 200, 1), 500);
|
||||
const rows = sql(
|
||||
`SELECT a.* FROM artifacts a WHERE ${where.join(" AND ")} ORDER BY a.updated_at DESC, a.id DESC LIMIT ${n}`
|
||||
).all(...params);
|
||||
return rows.map((r) => view(r, req));
|
||||
const owners = new Map();
|
||||
return rows.map((r) => view(r, req, owners));
|
||||
}
|
||||
|
||||
// ── Small helpers ─────────────────────────────────────────────────────────
|
||||
@@ -176,8 +191,7 @@ function cleanTitle(v, fallback) {
|
||||
const t = typeof v === "string" ? v.trim().slice(0, TITLE_MAX) : "";
|
||||
return t || String(fallback || "Untitled").slice(0, TITLE_MAX);
|
||||
}
|
||||
const cleanDescription = (v) =>
|
||||
typeof v === "string" ? v.trim().slice(0, DESCRIPTION_MAX) : "";
|
||||
const cleanDescription = (v) => (typeof v === "string" ? v.trim().slice(0, DESCRIPTION_MAX) : "");
|
||||
|
||||
function insert(fields) {
|
||||
const cols = Object.keys(fields);
|
||||
@@ -215,9 +229,9 @@ function sessionOwner(sessionId) {
|
||||
function sessionLane(sessionId) {
|
||||
const row =
|
||||
sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sessionId)) ||
|
||||
sql("SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL LIMIT 1").get(
|
||||
String(sessionId)
|
||||
);
|
||||
sql(
|
||||
"SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL LIMIT 1"
|
||||
).get(String(sessionId));
|
||||
return row?.lane_id ?? null;
|
||||
}
|
||||
|
||||
@@ -235,9 +249,10 @@ function validPageUrl(url) {
|
||||
function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||
const u = validPageUrl(url);
|
||||
const ownerId = req?.user ? req.user.id : null;
|
||||
const same = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?"
|
||||
).get(u, ownerId);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
|
||||
u,
|
||||
ownerId
|
||||
);
|
||||
if (same) {
|
||||
sql(
|
||||
"UPDATE artifacts SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
@@ -272,9 +287,10 @@ function captureFromHook(sessionId, data) {
|
||||
const url = m[0].replace(/[\\"]+$/, "");
|
||||
if (!PAGE_URL_RE.test(url)) return null;
|
||||
const ownerId = sessionOwner(sessionId);
|
||||
const same = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?"
|
||||
).get(url, ownerId);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
|
||||
url,
|
||||
ownerId
|
||||
);
|
||||
if (same) return same;
|
||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||
return insert({
|
||||
@@ -370,7 +386,7 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
kind: "file",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description),
|
||||
owner_id: req?.user ? req.user.id : lane.owner_id ?? null,
|
||||
owner_id: req?.user ? req.user.id : (lane.owner_id ?? null),
|
||||
lane_id: lane.id,
|
||||
session_id: sessionId ? String(sessionId) : null,
|
||||
source: relPosix,
|
||||
@@ -391,7 +407,11 @@ function uploadFile(req, { name, data, title, description }) {
|
||||
if (data.length > FILE_MAX_BYTES) throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
||||
const base = path.posix.basename(String(name || "").replace(/\\/g, "/"));
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const clean = base.replace(/[\x00-\x1f/]/g, "").replace(/^\.+/, "").slice(0, 200) || "file";
|
||||
const clean =
|
||||
base
|
||||
.replace(/[\x00-\x1f/]/g, "")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(0, 200) || "file";
|
||||
const snap = newSnapshotDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(snap.dir, clean), data, { mode: 0o600 });
|
||||
@@ -530,7 +550,8 @@ function addSkill(req, { name, laneId, title, description }) {
|
||||
if (!inside(top, real) || path.basename(real) !== name) {
|
||||
throw fail("ESYMLINK", "the skill folder is a link elsewhere");
|
||||
}
|
||||
if (!fs.existsSync(path.join(real, "SKILL.md"))) throw fail("ENOFILE", "no SKILL.md in that folder");
|
||||
if (!fs.existsSync(path.join(real, "SKILL.md")))
|
||||
throw fail("ENOFILE", "no SKILL.md in that folder");
|
||||
const { files } = walkSkill(real);
|
||||
const runAs = require("./run-as");
|
||||
return storeSkill(req, {
|
||||
@@ -577,7 +598,10 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
}
|
||||
let summary = "";
|
||||
try {
|
||||
summary = frontmatterField(fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"), "description");
|
||||
summary = frontmatterField(
|
||||
fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"),
|
||||
"description"
|
||||
);
|
||||
} catch {
|
||||
/* no frontmatter */
|
||||
}
|
||||
@@ -624,7 +648,10 @@ function skillSlug(text) {
|
||||
function validSkillName(raw) {
|
||||
const name = skillSlug(raw);
|
||||
if (!SKILL_NAME_RE.test(name)) {
|
||||
throw fail("EBADNAME", "skill name: letters, digits, '.', '_' or '-' (it becomes the folder and the /command)");
|
||||
throw fail(
|
||||
"EBADNAME",
|
||||
"skill name: letters, digits, '.', '_' or '-' (it becomes the folder and the /command)"
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -638,8 +665,10 @@ function cleanUploadPath(raw) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!p || /[\x00-\x1f]/.test(p)) throw fail("EBADPATH", "an uploaded file has no usable name");
|
||||
const parts = p.split("/").filter(Boolean);
|
||||
if (parts.some((x) => x === ".." || x === ".")) throw fail("EBADPATH", `not a path inside the skill: ${p}`);
|
||||
if (parts.length > SKILL_MAX_DEPTH + 2) throw fail("ETOOBIG", "the skill folder is nested too deep");
|
||||
if (parts.some((x) => x === ".." || x === "."))
|
||||
throw fail("EBADPATH", `not a path inside the skill: ${p}`);
|
||||
if (parts.length > SKILL_MAX_DEPTH + 2)
|
||||
throw fail("ETOOBIG", "the skill folder is nested too deep");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
@@ -684,7 +713,9 @@ function skillsFromUpload(uploads, { name, description }) {
|
||||
const p = cleanUploadPath(u.path);
|
||||
if (JUNK_RE.test(p)) continue;
|
||||
if (ZIP_RE.test(p) && uploads.length === 1) {
|
||||
files.push(...unzipSkill(u.data).map((f) => ({ path: cleanUploadPath(f.path), data: f.data })));
|
||||
files.push(
|
||||
...unzipSkill(u.data).map((f) => ({ path: cleanUploadPath(f.path), data: f.data }))
|
||||
);
|
||||
} else {
|
||||
files.push({ path: p, data: u.data });
|
||||
}
|
||||
@@ -760,8 +791,16 @@ function checkSkillFiles(files) {
|
||||
function withFrontmatter(text, name, description) {
|
||||
const body = String(text).replace(/^\uFEFF/, "");
|
||||
const fm = /^---\r?\n([\s\S]*?)\r?\n---[^\n]*(\r?\n|$)/.exec(body);
|
||||
const desc = String(description || "").trim().replace(/\s+/g, " ");
|
||||
const quote = (v) => (/^[\w .,()/-]*$/.test(v) && !/^[\d.]+$/.test(v) ? v : JSON.stringify(v));
|
||||
const desc = String(description || "")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
// Plain only when YAML cannot read it as anything else: starts with a
|
||||
// letter or digit (never `-`, `?`, `[`… — `- note` is a list item), no
|
||||
// trailing space, not a bare number. Anything else goes in double quotes.
|
||||
const quote = (v) =>
|
||||
/^[A-Za-z0-9][\w .,()/-]*$/.test(v) && !/\s$/.test(v) && !/^[\d.]+$/.test(v)
|
||||
? v
|
||||
: JSON.stringify(v);
|
||||
if (!fm) {
|
||||
if (!desc) {
|
||||
throw fail(
|
||||
@@ -773,12 +812,28 @@ function withFrontmatter(text, name, description) {
|
||||
}
|
||||
let lines = fm[1].split(/\r?\n/);
|
||||
const has = (k) => lines.some((l) => new RegExp(`^${k}:`).test(l));
|
||||
lines = has("name")
|
||||
? lines.map((l) => (/^name:/.test(l) ? `name: ${name}` : l))
|
||||
: [`name: ${name}`, ...lines];
|
||||
// Replace a top-level key AND its continuation lines (a `>`/`|` block or a
|
||||
// wrapped value is indented under it) — else the old text stays behind
|
||||
// and YAML folds it into the new value.
|
||||
const setKey = (k, value) => {
|
||||
const out = [];
|
||||
let skipping = false;
|
||||
for (const l of lines) {
|
||||
if (new RegExp(`^${k}:`).test(l)) {
|
||||
out.push(`${k}: ${value}`);
|
||||
skipping = true;
|
||||
continue;
|
||||
}
|
||||
if (skipping && (/^\s/.test(l) || l === "")) continue;
|
||||
skipping = false;
|
||||
out.push(l);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
lines = has("name") ? setKey("name", name) : [`name: ${name}`, ...lines];
|
||||
if (desc) {
|
||||
lines = has("description")
|
||||
? lines.map((l) => (/^description:/.test(l) ? `description: ${quote(desc)}` : l))
|
||||
? setKey("description", quote(desc))
|
||||
: [...lines, `description: ${quote(desc)}`];
|
||||
} else if (!frontmatterField(body, "description")) {
|
||||
throw fail(
|
||||
@@ -835,7 +890,8 @@ function writeSkill(req, { name, description, content, title }) {
|
||||
const n = validSkillName(name);
|
||||
const text = typeof content === "string" ? content : "";
|
||||
if (!text.trim()) throw fail("EBADSKILL", "write the skill's instructions");
|
||||
if (Buffer.byteLength(text) > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
if (Buffer.byteLength(text) > SKILL_MAX_BYTES)
|
||||
throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
return saveSkillFiles(req, {
|
||||
name: n,
|
||||
files: [{ path: "SKILL.md", data: Buffer.from(text, "utf8") }],
|
||||
@@ -889,15 +945,37 @@ async function fetchMarketJson(market) {
|
||||
if (/^https:\/\/.*\.json$/i.test(market)) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(market, { signal: AbortSignal.timeout(MARKET_TIMEOUT_MS), redirect: "follow" });
|
||||
res = await fetch(market, {
|
||||
signal: AbortSignal.timeout(MARKET_TIMEOUT_MS),
|
||||
redirect: "follow",
|
||||
});
|
||||
} catch (e) {
|
||||
throw fail("EMARKETFETCH", `could not read ${market}: ${e.message}`);
|
||||
}
|
||||
if (!res.url.startsWith("https://")) throw fail("EMARKETFETCH", "the link redirected off https");
|
||||
if (!res.url.startsWith("https://"))
|
||||
throw fail("EMARKETFETCH", "the link redirected off https");
|
||||
if (!res.ok) throw fail("EMARKETFETCH", `${market} answered ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (text.length > MARKET_JSON_MAX) throw fail("ETOOBIG", "marketplace.json is larger than 1 MB");
|
||||
return text;
|
||||
// Read at most 1 MB: a link the user names may serve anything, and
|
||||
// `res.text()` would hold all of it in memory before the size check.
|
||||
const tooBig = () => fail("ETOOBIG", "marketplace.json is larger than 1 MB");
|
||||
if (Number(res.headers.get("content-length")) > MARKET_JSON_MAX) {
|
||||
res.body?.cancel().catch(() => {});
|
||||
throw tooBig();
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
const reader = res.body?.getReader();
|
||||
while (reader) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.length;
|
||||
if (size > MARKET_JSON_MAX) {
|
||||
reader.cancel().catch(() => {});
|
||||
throw tooBig();
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
const url = cloneUrl(market);
|
||||
if (!url) {
|
||||
@@ -912,32 +990,59 @@ async function fetchMarketJson(market) {
|
||||
// so a URL a browser names never receives a token stored for another use.
|
||||
const env = { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" };
|
||||
const safe = [
|
||||
"-c", "credential.helper=",
|
||||
"-c", "protocol.allow=never",
|
||||
"-c", "protocol.https.allow=always",
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
"protocol.allow=never",
|
||||
"-c",
|
||||
"protocol.https.allow=always",
|
||||
"-c",
|
||||
"core.hooksPath=/dev/null",
|
||||
];
|
||||
try {
|
||||
try {
|
||||
await git(tmp, [...safe, "clone", "--depth", "1", "--filter=blob:none", "--no-checkout", "--quiet", "--", url, "repo"], {
|
||||
literal: true,
|
||||
timeout: MARKET_TIMEOUT_MS,
|
||||
env,
|
||||
});
|
||||
await git(
|
||||
tmp,
|
||||
[
|
||||
...safe,
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--filter=blob:none",
|
||||
"--no-checkout",
|
||||
"--quiet",
|
||||
"--",
|
||||
url,
|
||||
"repo",
|
||||
],
|
||||
{
|
||||
literal: true,
|
||||
timeout: MARKET_TIMEOUT_MS,
|
||||
env,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
const why = String(e?.git?.stderr || e?.stderr || e?.message || "");
|
||||
if (/terminal prompts disabled|could not read Username|Authentication failed|403|401/i.test(why)) {
|
||||
if (
|
||||
/terminal prompts disabled|could not read Username|Authentication failed|403|401/i.test(why)
|
||||
) {
|
||||
throw fail(
|
||||
"ENEEDLOGIN",
|
||||
"the repository does not exist, or it needs a login to read — check the link, or type the plugin as name@marketplace by hand (installing uses each user's own git login)"
|
||||
);
|
||||
}
|
||||
if (/not found|does not exist|404/i.test(why)) throw fail("EMARKETFETCH", "no repository at that link");
|
||||
if (/not found|does not exist|404/i.test(why))
|
||||
throw fail("EMARKETFETCH", "no repository at that link");
|
||||
throw fail("EMARKETFETCH", `could not read the repository: ${why.trim().split("\n").pop()}`);
|
||||
}
|
||||
const repo = path.join(tmp, "repo");
|
||||
const show = (p) =>
|
||||
git(repo, ["show", `HEAD:${p}`], { literal: true, timeout: MARKET_TIMEOUT_MS, env, maxBuffer: MARKET_JSON_MAX }).then(
|
||||
git(repo, ["show", `HEAD:${p}`], {
|
||||
literal: true,
|
||||
timeout: MARKET_TIMEOUT_MS,
|
||||
env,
|
||||
maxBuffer: MARKET_JSON_MAX,
|
||||
}).then(
|
||||
(r) => (typeof r === "string" ? r : r.stdout),
|
||||
() => null
|
||||
);
|
||||
@@ -983,7 +1088,12 @@ async function inspectMarket(raw) {
|
||||
version: str(p.version, 50),
|
||||
category: str(p.category, 50),
|
||||
}));
|
||||
return { market, name, description: str(json.description || json.metadata?.description), plugins };
|
||||
return {
|
||||
market,
|
||||
name,
|
||||
description: str(json.description || json.metadata?.description),
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
|
||||
function __setInspectImpl(fn) {
|
||||
@@ -999,10 +1109,9 @@ function addPlugin(req, { key, market, title, description }) {
|
||||
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
const ownerId = req?.user ? req.user.id : null;
|
||||
const src = normalizeMarket(market);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'plugin' AND source = ? AND owner_id IS ?").get(
|
||||
k,
|
||||
ownerId
|
||||
);
|
||||
const same = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'plugin' AND source = ? AND owner_id IS ?"
|
||||
).get(k, ownerId);
|
||||
if (same) {
|
||||
sql(
|
||||
`UPDATE artifacts SET market = ?, title = ?, description = ?,
|
||||
@@ -1102,7 +1211,12 @@ function snapshotFile(req, id, rel) {
|
||||
const want = typeof rel === "string" && rel ? rel : files[0]?.path;
|
||||
const hit = files.find((f) => f.path === want);
|
||||
if (!hit) throw fail("ENOFILE", "no such file in this artifact");
|
||||
return { row, abs: path.join(root(), row.snapshot, ...hit.path.split("/")), name: path.basename(hit.path), size: hit.size };
|
||||
return {
|
||||
row,
|
||||
abs: path.join(root(), row.snapshot, ...hit.path.split("/")),
|
||||
name: path.basename(hit.path),
|
||||
size: hit.size,
|
||||
};
|
||||
}
|
||||
|
||||
// ── install ───────────────────────────────────────────────────────────────
|
||||
@@ -1146,7 +1260,10 @@ async function installSkill(req, row, { replace = false } = {}) {
|
||||
);
|
||||
if (acct) {
|
||||
if (!runAs.writeAsCanMove()) {
|
||||
throw fail("EUPGRADE", "replacing needs the updated helper: run `sudo agenthub server install-system` once");
|
||||
throw fail(
|
||||
"EUPGRADE",
|
||||
"replacing needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
runAs.mkdirAs(acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(acct, target, ["move", aside]);
|
||||
|
||||
@@ -89,6 +89,8 @@ const STAFF_ONLY_PREFIXES = [
|
||||
"/alerts/rules",
|
||||
"/users",
|
||||
"/accounts",
|
||||
// The machine's hardware and every user's CPU/memory share.
|
||||
"/host",
|
||||
];
|
||||
// The run form's `/` autocomplete lists commands and skills.
|
||||
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file The workspace File Explorer (`/api/lanes/:id/fs/*`): list a folder,
|
||||
* make a folder or an empty file, rename, move and copy (within a workspace
|
||||
* or into another of the same account), delete into a per-account TRASH that
|
||||
* can be restored (kept TRASH_DAYS), upload a file streamed from the request
|
||||
* can be restored (kept TRASH_DAYS) — and, for `/api/homes`, one recycle bin
|
||||
* per USER over every store their deletes went to — upload a file streamed from the request
|
||||
* into any (new) sub-folder, and download files or folders as one streamed zip.
|
||||
*
|
||||
* Every path is the read path's own (git-diff.js): `lanePath` (lexical, no
|
||||
@@ -17,7 +18,7 @@
|
||||
* Writes run as the folder's ACCOUNT (run-as.js): a member's through
|
||||
* `write-as` (sudo, as them — the host never writes in their home), the
|
||||
* host's directly. A move/copy/trash in a member's folder needs write-as's
|
||||
* `move`/`copy` (`EUPGRADE` until `sudo agenthub server install-system` has
|
||||
* `move`/`copy` (`EUPGRADE` until `agenthub server install-system` has
|
||||
* installed it). A copy or move never crosses accounts (`EFOREIGN`).
|
||||
*
|
||||
* A name that is taken is never silently replaced: `conflict` decides —
|
||||
@@ -140,7 +141,7 @@ function needMove(acct) {
|
||||
if (acct && !runAs.writeAsCanMove()) {
|
||||
throw bad(
|
||||
"EUPGRADE",
|
||||
"moving or copying in a member's folder needs the updated helper: run `sudo agenthub server install-system` once"
|
||||
"moving or copying in a member's folder needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -468,12 +469,17 @@ async function dropTrash(acct, idDir) {
|
||||
async function listTrash(dir, userKey) {
|
||||
const root = await laneRoot(dir);
|
||||
const acct = accountAt(root);
|
||||
const tr = trashRoot(acct, userKey);
|
||||
const items = await readStore(acct, trashRoot(acct, userKey));
|
||||
return { items, keepDays: TRASH_DAYS };
|
||||
}
|
||||
|
||||
/** The live items of one trash folder `tr` (of `acct`), expired ones dropped. */
|
||||
async function readStore(acct, tr, store) {
|
||||
let ids = [];
|
||||
try {
|
||||
ids = (await fs.promises.readdir(tr)).filter((n) => TRASH_ID_RE.test(n));
|
||||
} catch {
|
||||
return { items: [] };
|
||||
return [];
|
||||
}
|
||||
const cutoff = Date.now() - TRASH_DAYS * 86_400_000;
|
||||
const items = [];
|
||||
@@ -490,15 +496,20 @@ async function listTrash(dir, userKey) {
|
||||
path: meta.rel,
|
||||
isDir: !!meta.isDir,
|
||||
deletedAt: meta.deletedAt,
|
||||
...(store ? { store } : {}),
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
return { items, keepDays: TRASH_DAYS };
|
||||
return items;
|
||||
}
|
||||
|
||||
function trashEntry(acct, userKey, id) {
|
||||
return entryAt(acct, trashRoot(acct, userKey), id);
|
||||
}
|
||||
|
||||
function entryAt(acct, tr, id) {
|
||||
if (!TRASH_ID_RE.test(String(id || ""))) throw bad("EBADPATH", "no such trash item");
|
||||
const idDir = path.join(trashRoot(acct, userKey), id);
|
||||
const idDir = path.join(tr, id);
|
||||
const meta = readMeta(acct, idDir);
|
||||
if (!meta) throw bad("ENOFILE", "no such trash item");
|
||||
return { idDir, meta };
|
||||
@@ -515,6 +526,11 @@ async function restore(dir, id, userKey, resolveLane) {
|
||||
const acct = accountAt(root);
|
||||
const { idDir, meta } = trashEntry(acct, userKey, id);
|
||||
const home = (meta.laneId != null && resolveLane(meta.laneId)) || dir;
|
||||
return putBack(acct, idDir, meta, home, dir);
|
||||
}
|
||||
|
||||
/** Move trash entry `idDir` back under folder `home` (`dir`: the fallback). */
|
||||
async function putBack(acct, idDir, meta, home, dir) {
|
||||
const parentRel = path.posix.dirname(String(meta.rel || meta.name));
|
||||
const relDir = parentRel === "." ? "" : parentRel;
|
||||
await ensureFolders(home, relDir);
|
||||
@@ -530,6 +546,77 @@ async function restore(dir, id, userKey, resolveLane) {
|
||||
return { path: relDir ? `${relDir}/${name}` : name, laneId: home === dir ? null : meta.laneId };
|
||||
}
|
||||
|
||||
// ── A user's recycle bin ───────────────────────────────────────────────────
|
||||
//
|
||||
// One Trash per USER, whatever place a delete happened in: the trash of their
|
||||
// home's account (a member's `~/.local/share/agenthub/trash/`, or the host's
|
||||
// `<data dir>/trash/<user id>/` when their home is the host's) plus — when
|
||||
// that is another folder — the host trash under their user id, where what
|
||||
// they deleted in a host-folder workspace went. Items carry `store`
|
||||
// ("home" | "host") so a restore or purge finds the right one.
|
||||
|
||||
/** The trash stores of the user `userKey` whose home is `homeDir`. */
|
||||
function userStores(homeDir, userKey) {
|
||||
const acct = accountAt(homeDir);
|
||||
const out = [{ store: "home", acct, root: trashRoot(acct, userKey) }];
|
||||
const host = trashRoot(null, userKey);
|
||||
if (acct || path.resolve(host) !== path.resolve(out[0].root)) {
|
||||
out.push({ store: "host", acct: null, root: host });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickStore(homeDir, userKey, store) {
|
||||
const s = userStores(homeDir, userKey).find((x) => x.store === (store || "home"));
|
||||
if (!s) throw bad("EBADPATH", "no such trash");
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Every item of the user's recycle bin, newest first. */
|
||||
async function listUserTrash(homeDir, userKey) {
|
||||
const items = [];
|
||||
for (const s of userStores(homeDir, userKey)) {
|
||||
items.push(...(await readStore(s.acct, s.root, s.store)));
|
||||
}
|
||||
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
return { items, keepDays: TRASH_DAYS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore item `id` of the user's `store`: into the workspace it came from
|
||||
* when `resolveLane` still gives one, else — for the home store — into the
|
||||
* home. A host-store item whose workspace is gone has nowhere of the same
|
||||
* account to go (`ENOFILE`).
|
||||
*/
|
||||
async function restoreUserTrash(homeDir, userKey, store, id, resolveLane) {
|
||||
const s = pickStore(homeDir, userKey, store);
|
||||
const { idDir, meta } = entryAt(s.acct, s.root, id);
|
||||
const fallback = s.store === "home" ? homeDir : null;
|
||||
const home = (meta.laneId != null && resolveLane(meta.laneId)) || fallback;
|
||||
if (!home) {
|
||||
throw bad("ENOFILE", "the workspace this came from is gone — there is nowhere to restore it");
|
||||
}
|
||||
return putBack(s.acct, idDir, meta, home, fallback);
|
||||
}
|
||||
|
||||
/** Delete item `id` of `store` forever, or `"all"` of every store. */
|
||||
async function purgeUserTrash(homeDir, userKey, store, id) {
|
||||
if (id === "all") {
|
||||
let n = 0;
|
||||
for (const s of userStores(homeDir, userKey)) {
|
||||
for (const it of await readStore(s.acct, s.root)) {
|
||||
await dropTrash(s.acct, path.join(s.root, it.id));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return { purged: n };
|
||||
}
|
||||
const s = pickStore(homeDir, userKey, store);
|
||||
const { idDir } = entryAt(s.acct, s.root, id);
|
||||
await dropTrash(s.acct, idDir);
|
||||
return { purged: 1 };
|
||||
}
|
||||
|
||||
async function purgeTrash(dir, userKey, id) {
|
||||
const root = await laneRoot(dir);
|
||||
const acct = accountAt(root);
|
||||
@@ -800,6 +887,9 @@ module.exports = {
|
||||
remove,
|
||||
trashOne,
|
||||
listTrash,
|
||||
listUserTrash,
|
||||
restoreUserTrash,
|
||||
purgeUserTrash,
|
||||
restore,
|
||||
purgeTrash,
|
||||
upload,
|
||||
|
||||
+222
-43
@@ -23,8 +23,14 @@
|
||||
* A plain `git` typed in a terminal reads none of that, so the account's own
|
||||
* `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on
|
||||
* by default for a member, a switch for the host; a user's own is never
|
||||
* overwritten), plus a per-host SSH login name (`setSshUser`: AWS CodeCommit
|
||||
* wants the IAM SSH key ID as the user) as a `url.<…>.insteadOf` rule there.
|
||||
* overwritten), plus SSH logins (`setSshUser`: AWS CodeCommit wants the IAM
|
||||
* SSH key ID as the user) as `url.<…>.insteadOf` rules there — one per host,
|
||||
* or one per ALIAS (`ssh://aws-prod/…`), so several accounts on one host
|
||||
* (two AWS accounts, two GitHub users) each log in as themselves. Besides
|
||||
* the default key of each type, an account may hold named keys
|
||||
* (`ssh/id_<type>_<name>`), and a login may name the key it uses
|
||||
* (`agenthub.<from>.sshKey` in the same file): a clone of that login's URL
|
||||
* offers ONLY that key and keeps it in the clone's config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -74,6 +80,12 @@ function accountForRequest(req) {
|
||||
*/
|
||||
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
|
||||
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
|
||||
/** A named key's name, and every key's id (= its file name in `sshDir`). */
|
||||
const KEY_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
const KEY_ID_RE = /^id_(ed25519|rsa)(?:_([a-z0-9][a-z0-9-]{0,31}))?$/;
|
||||
/** ssh offers every key in turn and servers stop after ~6 tries. */
|
||||
const MAX_KEYS = 10;
|
||||
const keyIdFor = (type, name) => (name ? `${KEY_FILES[type]}_${name}` : KEY_FILES[type]);
|
||||
|
||||
function paths(account) {
|
||||
if (account.kind === "member") {
|
||||
@@ -214,17 +226,32 @@ function fingerprint(publicKey) {
|
||||
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
/** `[{type, file, publicKey}]` of the account's keys, ed25519 first. */
|
||||
/**
|
||||
* `[{id, type, name, file, publicKey}]` of the account's keys: the default
|
||||
* ed25519 and rsa ones first (`name` null), then the named ones by name.
|
||||
*/
|
||||
function currentKeys(account) {
|
||||
const { sshDir } = paths(account);
|
||||
let ids;
|
||||
try {
|
||||
ids = fs.readdirSync(sshDir).filter((n) => KEY_ID_RE.test(n));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const rank = (id) => {
|
||||
const i = Object.values(KEY_FILES).indexOf(id);
|
||||
return i < 0 ? Object.keys(KEY_FILES).length : i;
|
||||
};
|
||||
ids.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
||||
const keys = [];
|
||||
for (const type of Object.keys(KEY_FILES)) {
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
for (const id of ids) {
|
||||
const [, type, name = null] = KEY_ID_RE.exec(id);
|
||||
const file = path.join(sshDir, id);
|
||||
const publicKey = readText(account, `${file}.pub`);
|
||||
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
|
||||
// The private half is only looked at, never read into this process.
|
||||
if (!isOwnFile(account, file)) continue;
|
||||
keys.push({ type, file, publicKey: publicKey.trim() });
|
||||
keys.push({ id, type, name, file, publicKey: publicKey.trim() });
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -235,10 +262,12 @@ const keyFiles = (account) => currentKeys(account).map((k) => k.file);
|
||||
/** ssh options naming `files` — `-i` each, in order. */
|
||||
const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join("");
|
||||
|
||||
/** `[{type, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
/** `[{id, type, name, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
function sshKeysInfo(account) {
|
||||
return currentKeys(account).map((k) => ({
|
||||
id: k.id,
|
||||
type: k.type,
|
||||
name: k.name,
|
||||
publicKey: k.publicKey,
|
||||
fingerprint: fingerprint(k.publicKey),
|
||||
}));
|
||||
@@ -248,14 +277,30 @@ function sshKeysInfo(account) {
|
||||
const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null;
|
||||
|
||||
/**
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa); an
|
||||
* existing one OF THAT TYPE is replaced only with `replace`. A key of the
|
||||
* other type is left alone — a host refusing one type keeps the other.
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa) — the
|
||||
* default one of that type, or with `name` one more beside it (another AWS
|
||||
* account, another GitHub user). An existing key of that id is replaced only
|
||||
* with `replace`; every other key is left alone.
|
||||
*/
|
||||
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
|
||||
async function generateSshKey(
|
||||
account,
|
||||
{ replace = false, comment, type = "ed25519", name = null } = {}
|
||||
) {
|
||||
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
if (currentKeys(account).some((k) => k.type === type) && !replace) {
|
||||
throw err("EEXISTS", `an ${type} SSH key already exists — replace it explicitly`);
|
||||
const keyName = name == null || name === "" ? null : String(name).trim().toLowerCase();
|
||||
if (keyName !== null && !KEY_NAME_RE.test(keyName)) {
|
||||
throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32");
|
||||
}
|
||||
const id = keyIdFor(type, keyName);
|
||||
const keys = currentKeys(account);
|
||||
if (keys.some((k) => k.id === id) && !replace) {
|
||||
throw err("EEXISTS", `the SSH key ${keyName ?? type} already exists — replace it explicitly`);
|
||||
}
|
||||
if (keyName !== null && keys.some((k) => k.name === keyName && k.id !== id)) {
|
||||
throw err("EEXISTS", `a key named ${keyName} already exists`);
|
||||
}
|
||||
if (!keys.some((k) => k.id === id) && keys.length >= MAX_KEYS) {
|
||||
throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`);
|
||||
}
|
||||
const label =
|
||||
String(comment || "agenthub")
|
||||
@@ -264,29 +309,44 @@ async function generateSshKey(account, { replace = false, comment, type = "ed255
|
||||
const { privateKey, publicKey } =
|
||||
type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label);
|
||||
const { sshDir } = paths(account);
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
const file = path.join(sshDir, id);
|
||||
writeText(account, file, privateKey, { secret: true });
|
||||
writeText(account, `${file}.pub`, publicKey, { secret: false });
|
||||
// A member's home is AgentHub's to set up: their terminal git uses the key
|
||||
// too, unless they wrote a core.sshCommand of their own. The host's is
|
||||
// its admin's choice (the Settings switch).
|
||||
if (account.kind === "member") await setTerminalSsh(account, true).catch(() => {});
|
||||
return sshKeysInfo(account).find((k) => k.type === type) ?? null;
|
||||
// its admin's choice (the Settings switch) — once on, it follows the keys.
|
||||
if (account.kind === "member" || (await terminalSshState(account)) === "on") {
|
||||
await setTerminalSsh(account, true).catch(() => {});
|
||||
}
|
||||
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account's key of `type`, or every key when `type` is omitted;
|
||||
* false when there was none. The terminal sshCommand goes with the last key.
|
||||
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
|
||||
* neither is given; false when there was none. A login naming a deleted key
|
||||
* goes back to offering every key; the terminal sshCommand follows the keys
|
||||
* and goes with the last one.
|
||||
*/
|
||||
async function removeSshKey(account, type) {
|
||||
async function removeSshKey(account, which) {
|
||||
const { type, id } = typeof which === "string" ? { type: which } : which || {};
|
||||
if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
const gone = currentKeys(account).filter((k) => type == null || k.type === type);
|
||||
if (id != null && !KEY_ID_RE.test(id)) throw err("EBADKEYNAME", "no such key id");
|
||||
const gone = currentKeys(account).filter((k) =>
|
||||
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
|
||||
);
|
||||
if (!gone.length) return false;
|
||||
for (const k of gone) {
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
}
|
||||
const ids = new Set(gone.map((k) => k.id));
|
||||
for (const [from, key] of await keyLinks(account)) {
|
||||
if (ids.has(key)) await unlinkKey(account, from);
|
||||
}
|
||||
if (!currentKeys(account).length) await setTerminalSsh(account, false).catch(() => {});
|
||||
else if ((await terminalSshState(account)) === "on") {
|
||||
await setTerminalSsh(account, true).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -386,6 +446,15 @@ async function getAuthor(account) {
|
||||
return { name: await get("user.name"), email: await get("user.email") };
|
||||
}
|
||||
|
||||
/** The domain of a suggested email when the account has no author yet. */
|
||||
const DEFAULT_EMAIL_DOMAIN = process.env.AGENTHUB_EMAIL_DOMAIN || "smartgift.vn";
|
||||
|
||||
/** `<username>@<DEFAULT_EMAIL_DOMAIN>`, or null without a usable name. */
|
||||
function defaultEmail(username) {
|
||||
const local = String(username || "").replace(/[^\w.+-]/g, "");
|
||||
return local ? `${local}@${DEFAULT_EMAIL_DOMAIN}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account's commit author. Neither is a secret, so they may cross
|
||||
* sudo's argv; each is one line, a name up to 100 and an email up to 254
|
||||
@@ -418,11 +487,28 @@ async function setAuthor(account, { name, email }) {
|
||||
*/
|
||||
function terminalSshCommand(account) {
|
||||
const { sshDir } = paths(account);
|
||||
const ours = Object.values(KEY_FILES).map((f) => `-o IdentityFile=${shq(path.join(sshDir, f))}`);
|
||||
const files = new Set([
|
||||
...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)),
|
||||
...currentKeys(account).map((k) => k.file),
|
||||
]);
|
||||
const ours = [...files].map((f) => `-o IdentityFile=${shq(f)}`);
|
||||
const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`);
|
||||
return ["ssh", ...ours, ...defaults].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `cmd` is a terminal sshCommand WE wrote — of our shape, naming
|
||||
* key files in this account's AgentHub folder (any set of them: it follows
|
||||
* the keys as they are made and deleted) and nothing else but the defaults.
|
||||
*/
|
||||
function isOurTerminal(account, cmd) {
|
||||
const m = /^ssh((?: -o IdentityFile=(?:'[^']*'|~\/\.ssh\/id_[a-z0-9]+))+)$/.exec(cmd || "");
|
||||
if (!m) return false;
|
||||
const files = [...m[1].matchAll(/IdentityFile='([^']*)'/g)].map((x) => x[1]);
|
||||
const { sshDir } = paths(account);
|
||||
return files.length > 0 && files.every((f) => path.dirname(f) === sshDir);
|
||||
}
|
||||
|
||||
/** The account's global `core.sshCommand`, or "" when unset. */
|
||||
async function globalSshCommand(account) {
|
||||
const { git } = require("./worktree");
|
||||
@@ -440,7 +526,7 @@ async function globalSshCommand(account) {
|
||||
async function terminalSshState(account) {
|
||||
const cmd = await globalSshCommand(account);
|
||||
if (!cmd) return "off";
|
||||
return cmd === terminalSshCommand(account) ? "on" : "foreign";
|
||||
return isOurTerminal(account, cmd) ? "on" : "foreign";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,7 +541,8 @@ async function setTerminalSsh(account, on) {
|
||||
if (state === "foreign") {
|
||||
throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is");
|
||||
}
|
||||
if (state === "off") {
|
||||
// Off, or ours naming another set of keys than the account holds now.
|
||||
if (state === "off" || (await globalSshCommand(account)) !== terminalSshCommand(account)) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
@@ -478,9 +565,47 @@ async function setTerminalSsh(account, on) {
|
||||
// Clone dialog's and the git bar's — without touching `~/.ssh/config`. The
|
||||
// user name is no secret (an ID, not a key), so it may cross sudo's argv.
|
||||
const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
|
||||
// An alias has no dot, so it can never shadow a real host name.
|
||||
const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
|
||||
|
||||
/** `[{host, user}]` — only the rules of exactly our shape; any other insteadOf is the user's. */
|
||||
/** `Map<from, key id>`: the key each login names (`agenthub.<from>.sshKey`). */
|
||||
async function keyLinks(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--get-regexp",
|
||||
"^agenthub\\..*\\.sshkey$",
|
||||
]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const links = new Map();
|
||||
for (const line of String(out).split("\n")) {
|
||||
const m = /^agenthub\.(.+)\.sshkey (\S+)$/.exec(line);
|
||||
if (m && KEY_ID_RE.test(m[2])) links.set(m[1], m[2]);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
async function unlinkKey(account, from) {
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--unset-all",
|
||||
`agenthub.${from}.sshKey`,
|
||||
]).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* `[{host, user, alias, key}]` — only the rules of exactly our shape; any
|
||||
* other insteadOf is the user's. `alias` is null for the host's own rule
|
||||
* (`ssh://<host>/` logs in as `user`), else the name standing for that
|
||||
* user@host (`ssh://<alias>/`); `key` is the key id the login uses, or null
|
||||
* for every key.
|
||||
*/
|
||||
async function listSshUsers(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
@@ -492,21 +617,35 @@ async function listSshUsers(account) {
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const links = await keyLinks(account);
|
||||
const rows = [];
|
||||
for (const line of out.split("\n")) {
|
||||
const m = /^url\.ssh:\/\/([^@/\s]+)@([^/\s]+)\/\.insteadof ssh:\/\/([^/\s]+)\/$/.exec(line);
|
||||
if (m && m[2] === m[3] && SSH_USER_RE.test(m[1]) && HOST_RE.test(m[2])) {
|
||||
rows.push({ host: m[2], user: m[1] });
|
||||
if (
|
||||
m &&
|
||||
(m[2] === m[3] || ALIAS_RE.test(m[3])) &&
|
||||
SSH_USER_RE.test(m[1]) &&
|
||||
HOST_RE.test(m[2])
|
||||
) {
|
||||
rows.push({
|
||||
host: m[2],
|
||||
user: m[1],
|
||||
alias: m[2] === m[3] ? null : m[3],
|
||||
key: links.get(m[3]) ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Drop our rule for `host` (whatever its user); false when there was none. */
|
||||
async function removeSshUser(account, host) {
|
||||
/**
|
||||
* Drop our rule for `from` — a host's own rule, or an alias's — and the key
|
||||
* it names; false when there was none.
|
||||
*/
|
||||
async function removeSshUser(account, from) {
|
||||
const { git } = require("./worktree");
|
||||
const h = String(host || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => r.host === h);
|
||||
const f = String(from || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => (r.alias ?? r.host) === f);
|
||||
for (const r of rows) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
@@ -514,34 +653,68 @@ async function removeSshUser(account, host) {
|
||||
"--fixed-value",
|
||||
"--unset-all",
|
||||
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
|
||||
`ssh://${r.host}/`,
|
||||
`ssh://${f}/`,
|
||||
]);
|
||||
}
|
||||
if (rows.length) await unlinkKey(account, f);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Log in to `host` over SSH as `user` (replaces our earlier rule for that host). */
|
||||
async function setSshUser(account, { host, user } = {}) {
|
||||
/**
|
||||
* Log in to `host` over SSH as `user` — for `ssh://<host>/` itself, or, with
|
||||
* `alias`, for `ssh://<alias>/` (one more account on the same host) — with
|
||||
* the key `key` only, or every key when it is null. Replaces our earlier rule
|
||||
* for that host or alias.
|
||||
*/
|
||||
async function setSshUser(account, { host, user, alias = null, key = null } = {}) {
|
||||
const h = String(host || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const u = String(user || "").trim();
|
||||
const a = alias == null || alias === "" ? null : String(alias).trim().toLowerCase();
|
||||
if (!HOST_RE.test(h)) {
|
||||
throw err("EBADHOST", "a host name like git-codecommit.us-east-1.amazonaws.com");
|
||||
}
|
||||
if (!SSH_USER_RE.test(u)) throw err("EBADSSHUSER", "a user name of letters, digits, . _ -");
|
||||
await removeSshUser(account, h);
|
||||
if (a !== null && !ALIAS_RE.test(a)) {
|
||||
throw err("EBADALIAS", "an account name of lowercase letters, digits and - (no dot)");
|
||||
}
|
||||
if (key != null && key !== "" && !currentKeys(account).some((k) => k.id === key)) {
|
||||
throw err("ENOKEY", "no such SSH key");
|
||||
}
|
||||
const from = a ?? h;
|
||||
await removeSshUser(account, from);
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
`url.${insteadOfBase(h, u)}.insteadOf`,
|
||||
`ssh://${h}/`,
|
||||
`ssh://${from}/`,
|
||||
]);
|
||||
if (key) await git(homeOf(account), ["config", "--global", `agenthub.${from}.sshKey`, key]);
|
||||
return listSshUsers(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key file a clone of `url` must use: the one its login names, when the
|
||||
* URL is `ssh://<host or alias>/…` with no user of its own (the only shape
|
||||
* our insteadOf rules rewrite). Null: offer every key.
|
||||
*/
|
||||
async function linkedKeyFile(account, url) {
|
||||
if (!url.startsWith("ssh://")) return null;
|
||||
let from;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.username) return null;
|
||||
from = u.host.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const row = (await listSshUsers(account)).find((r) => (r.alias ?? r.host) === from && r.key);
|
||||
return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null;
|
||||
}
|
||||
|
||||
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
|
||||
|
||||
/**
|
||||
@@ -573,7 +746,7 @@ async function sshConfigFor(dir) {
|
||||
}
|
||||
// Our own terminal sshCommand (global) is not the repo's choice: it has no
|
||||
// BatchMode, so it would leave a dashboard fetch waiting on a prompt.
|
||||
if (own && own !== terminalSshCommand(account)) return [];
|
||||
if (own && !isOurTerminal(account, own)) return [];
|
||||
return [
|
||||
"-c",
|
||||
`core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
@@ -683,18 +856,23 @@ async function cloneRepo({ url, parent, name, once = null }) {
|
||||
|
||||
if (remote.scheme === "ssh") {
|
||||
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
|
||||
const keys = keyFiles(account);
|
||||
// A login naming its key (another AWS account, a second GitHub user)
|
||||
// offers that key ONLY — another key could log in as the wrong account.
|
||||
const linked = await linkedKeyFile(account, url);
|
||||
const keys = linked ? [linked] : keyFiles(account);
|
||||
const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
|
||||
// BatchMode: no terminal to answer a prompt; accept-new: first contact
|
||||
// records the host key, a CHANGED one still fails.
|
||||
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
|
||||
if (keys.length) {
|
||||
// Persisted by FILE name, both types: a key made later is offered too
|
||||
// (ssh skips a missing IdentityFile silently, where `-i` would warn).
|
||||
// Persisted by FILE name — both default types plus every named key:
|
||||
// a default key made later is offered too (ssh skips a missing
|
||||
// IdentityFile silently, where `-i` would warn). A linked key alone.
|
||||
const { sshDir } = paths(account);
|
||||
const all = Object.values(KEY_FILES)
|
||||
.map((f) => ` -o IdentityFile=${shq(path.join(sshDir, f))}`)
|
||||
.join("");
|
||||
const files = linked
|
||||
? [linked]
|
||||
: [...new Set([...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)), ...keys])];
|
||||
const all = files.map((f) => ` -o IdentityFile=${shq(f)}`).join("");
|
||||
persist.push("-c", `core.sshCommand=ssh${all} -o IdentitiesOnly=yes`);
|
||||
}
|
||||
} else if (account.kind === "member") {
|
||||
@@ -798,6 +976,7 @@ module.exports = {
|
||||
defaultName,
|
||||
cloneRepo,
|
||||
getAuthor,
|
||||
defaultEmail,
|
||||
setAuthor,
|
||||
sshConfigFor,
|
||||
memberHttpsConfig,
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* @file host-stats.js
|
||||
* @description The Administration page's Server tab (`GET /api/host`, staff
|
||||
* only): the machine's hardware and load, and how much of its CPU and memory
|
||||
* each AgentHub user is using right now. Read-only — nothing here signals,
|
||||
* renices or kills a process.
|
||||
*
|
||||
* Hardware comes from `os` plus `/proc` (meminfo, stat, mounts) and `statfs`
|
||||
* per real mounted file system (HDD/SSD from `/sys/block/<dev>/queue/rotational`);
|
||||
* a GPU only when `nvidia-smi` answers within 3 s. Usage is two `/proc`
|
||||
* samples `SAMPLE_MS` apart: a process's CPU % is its utime+stime delta over
|
||||
* the whole machine's jiffies delta (100 % = every core busy), its memory its
|
||||
* RSS. A process is attributed, in order, to: the AgentHub user of its uid
|
||||
* (`ah-<name>` under S3, `memberNameForUid`); for a process of the host
|
||||
* account, the OWNER of the workspace named by its inherited
|
||||
* `AGENTHUB_LANE_ID` (every pane gets it through tmux `-e`, so an agent's
|
||||
* children carry it too); else `host` (the dashboard and everything else the
|
||||
* host account runs) or `system` (root and every other account). Only the
|
||||
* kernel's `comm` name is reported for a process — never its argv or env,
|
||||
* which can hold a prompt or a key. Every read is async, and one sample is
|
||||
* shared by concurrent callers and cached for `FRESH_MS`. Linux only: other
|
||||
* platforms get the `os` facts with `usage: null`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const SAMPLE_MS = 1000;
|
||||
const FRESH_MS = 3000;
|
||||
const TOP_PER_GROUP = 5;
|
||||
// File systems that hold data; pseudo and loop mounts are left out.
|
||||
const REAL_FS = new Set([
|
||||
"ext2",
|
||||
"ext3",
|
||||
"ext4",
|
||||
"xfs",
|
||||
"btrfs",
|
||||
"zfs",
|
||||
"f2fs",
|
||||
"vfat",
|
||||
"exfat",
|
||||
"ntfs",
|
||||
"ntfs3",
|
||||
"fuseblk",
|
||||
"nfs",
|
||||
"nfs4",
|
||||
"cifs",
|
||||
]);
|
||||
|
||||
const readText = (file) => fsp.readFile(file, "utf8").catch(() => null);
|
||||
|
||||
/** `/proc/meminfo` in bytes: total, available, swap. */
|
||||
async function memory() {
|
||||
const text = await readText("/proc/meminfo");
|
||||
const kb = (key) => {
|
||||
const m = text && new RegExp(`^${key}:\\s+(\\d+)`, "m").exec(text);
|
||||
return m ? Number(m[1]) * 1024 : null;
|
||||
};
|
||||
const total = kb("MemTotal") ?? os.totalmem();
|
||||
const available = kb("MemAvailable") ?? os.freemem();
|
||||
return {
|
||||
total,
|
||||
available,
|
||||
used: total - available,
|
||||
swapTotal: kb("SwapTotal") ?? 0,
|
||||
swapUsed: (kb("SwapTotal") ?? 0) - (kb("SwapFree") ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** The machine's jiffies from `/proc/stat`'s `cpu` line: `{ total, idle }`. */
|
||||
async function cpuTimes() {
|
||||
const text = await readText("/proc/stat");
|
||||
const line = text && text.split("\n").find((l) => l.startsWith("cpu "));
|
||||
if (!line) return null;
|
||||
const n = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
return { total: n.reduce((a, b) => a + b, 0), idle: n[3] + (n[4] || 0) };
|
||||
}
|
||||
|
||||
/** `HDD`/`SSD` for a `/dev/...` device, or null when /sys cannot tell. */
|
||||
function diskKind(device) {
|
||||
if (!device.startsWith("/dev/")) return null;
|
||||
let name = path.basename(device);
|
||||
try {
|
||||
name = path.basename(fs.realpathSync(device));
|
||||
} catch {
|
||||
/* a mapper name that does not resolve: try it as is */
|
||||
}
|
||||
// A partition's queue lives on its parent: sda1 → sda, nvme0n1p2 → nvme0n1.
|
||||
const candidates = [name, name.replace(/p?\d+$/, "")];
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const v = fs.readFileSync(`/sys/block/${c}/queue/rotational`, "utf8").trim();
|
||||
return v === "1" ? "HDD" : "SSD";
|
||||
} catch {
|
||||
/* next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every real mounted file system, one row per device. */
|
||||
async function disks() {
|
||||
const text = await readText("/proc/mounts");
|
||||
if (!text) return [];
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const [device, rawMount, type] = line.split(" ");
|
||||
if (!device || !REAL_FS.has(type) || seen.has(device)) continue;
|
||||
if (device.startsWith("/dev/loop")) continue;
|
||||
const mount = rawMount.replace(/\\040/g, " ");
|
||||
if (mount.startsWith("/snap/") || mount.startsWith("/boot/efi")) continue;
|
||||
seen.add(device);
|
||||
try {
|
||||
const st = await fsp.statfs(mount);
|
||||
const total = st.blocks * st.bsize;
|
||||
if (!total) continue;
|
||||
const free = st.bavail * st.bsize;
|
||||
out.push({
|
||||
device,
|
||||
mount,
|
||||
type,
|
||||
kind: diskKind(device),
|
||||
total,
|
||||
used: total - st.bfree * st.bsize,
|
||||
free,
|
||||
});
|
||||
} catch {
|
||||
/* unreadable mount: skip */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The mount (from `list`) holding `p`: the longest mount point that prefixes it. */
|
||||
function mountOf(list, p) {
|
||||
if (!p) return null;
|
||||
let best = null;
|
||||
for (const d of list) {
|
||||
const m = d.mount === "/" ? "/" : `${d.mount}/`;
|
||||
if ((p === d.mount || p.startsWith(m)) && (!best || d.mount.length > best.mount.length))
|
||||
best = d;
|
||||
}
|
||||
return best ? best.mount : null;
|
||||
}
|
||||
|
||||
/** `nvidia-smi` GPUs, or [] when there is none (or it does not answer in 3 s). */
|
||||
function gpus() {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"nvidia-smi",
|
||||
[
|
||||
"--query-gpu=name,utilization.gpu,memory.used,memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
{ timeout: 3000, windowsHide: true },
|
||||
(err, stdout) => {
|
||||
if (err) return resolve([]);
|
||||
resolve(
|
||||
String(stdout)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => {
|
||||
const [name, util, used, total] = l.split(",").map((s) => s.trim());
|
||||
return {
|
||||
name,
|
||||
utilization: Number(util) || 0,
|
||||
memoryUsed: (Number(used) || 0) * 1024 * 1024,
|
||||
memoryTotal: (Number(total) || 0) * 1024 * 1024,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function distro() {
|
||||
try {
|
||||
const m = /^PRETTY_NAME="?([^"\n]*)"?/m.exec(fs.readFileSync("/etc/os-release", "utf8"));
|
||||
return m ? m[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE = 4096;
|
||||
|
||||
/**
|
||||
* One pass over `/proc`: `Map<pid, { uid, comm, ticks, rss }>`. `stat`'s comm
|
||||
* is in parentheses and may itself hold spaces or `)`, so the fields are read
|
||||
* after the LAST `)`.
|
||||
*/
|
||||
async function processes() {
|
||||
const out = new Map();
|
||||
let names;
|
||||
try {
|
||||
names = await fsp.readdir("/proc");
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
await Promise.all(
|
||||
names
|
||||
.filter((n) => /^\d+$/.test(n))
|
||||
.map(async (pid) => {
|
||||
try {
|
||||
const [stat, st] = await Promise.all([
|
||||
fsp.readFile(`/proc/${pid}/stat`, "utf8"),
|
||||
fsp.stat(`/proc/${pid}`),
|
||||
]);
|
||||
const close = stat.lastIndexOf(")");
|
||||
const comm = stat.slice(stat.indexOf("(") + 1, close);
|
||||
const f = stat.slice(close + 2).split(" ");
|
||||
// After comm: state(0) ppid(1) … utime(11) stime(12) … rss(21).
|
||||
out.set(Number(pid), {
|
||||
uid: st.uid,
|
||||
comm,
|
||||
ticks: Number(f[11]) + Number(f[12]),
|
||||
rss: Number(f[21]) * PAGE,
|
||||
});
|
||||
} catch {
|
||||
/* exited meanwhile */
|
||||
}
|
||||
})
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The `AGENTHUB_LANE_ID` a host process inherited, or null. */
|
||||
async function laneIdOf(pid) {
|
||||
try {
|
||||
const env = await fsp.readFile(`/proc/${pid}/environ`, "utf8");
|
||||
const m = /(?:^|\0)AGENTHUB_LANE_ID=(\d+)(?:\0|$)/.exec(env);
|
||||
return m ? Number(m[1]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Defaults wired to the real modules; a test swaps them (`__setDeps`). */
|
||||
let deps = {
|
||||
memberNameForUid: (uid) => require("./run-as").memberNameForUid(uid),
|
||||
laneOwner: (laneId) => {
|
||||
const { db } = require("../db");
|
||||
const row = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId);
|
||||
return row ? row.owner_id : null;
|
||||
},
|
||||
users: () => {
|
||||
try {
|
||||
return require("./auth").listUsers();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
homesRoot: () => {
|
||||
try {
|
||||
const linux = require("./linux-accounts");
|
||||
if (linux.s3Enabled()) return linux.serverConf().usersRoot;
|
||||
} catch {
|
||||
/* no S3 */
|
||||
}
|
||||
return process.env.AGENTHUB_USERS_ROOT || os.homedir();
|
||||
},
|
||||
wait: (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
};
|
||||
|
||||
/** Per-user CPU and memory from two samples. */
|
||||
async function usage() {
|
||||
const t0 = await cpuTimes();
|
||||
const p0 = await processes();
|
||||
await deps.wait(SAMPLE_MS);
|
||||
const t1 = await cpuTimes();
|
||||
const p1 = await processes();
|
||||
const mem = await memory();
|
||||
if (!t0 || !t1) return null;
|
||||
const totalDelta = Math.max(1, t1.total - t0.total);
|
||||
const hostUid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
|
||||
const users = deps.users();
|
||||
const byUsername = new Map(users.map((u) => [u.username, u]));
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
const ownerCache = new Map();
|
||||
const groups = new Map();
|
||||
const group = (key, extra) => {
|
||||
if (!groups.has(key))
|
||||
groups.set(key, { key, cpu: 0, memory: 0, processes: 0, top: [], ...extra });
|
||||
return groups.get(key);
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
[...p1].map(async ([pid, p]) => {
|
||||
const before = p0.get(pid);
|
||||
// A process born between the samples: all its ticks are in the window.
|
||||
const ticks = Math.max(0, p.ticks - (before ? before.ticks : 0));
|
||||
let g = null;
|
||||
const member = deps.memberNameForUid(p.uid);
|
||||
if (member && byUsername.has(member)) {
|
||||
const u = byUsername.get(member);
|
||||
g = group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username });
|
||||
} else if (p.uid === hostUid) {
|
||||
const laneId = await laneIdOf(pid);
|
||||
let ownerId = null;
|
||||
if (laneId != null) {
|
||||
if (!ownerCache.has(laneId)) {
|
||||
let o = null;
|
||||
try {
|
||||
o = deps.laneOwner(laneId);
|
||||
} catch {
|
||||
o = null;
|
||||
}
|
||||
ownerCache.set(laneId, o);
|
||||
}
|
||||
ownerId = ownerCache.get(laneId);
|
||||
}
|
||||
const u = ownerId != null ? byId.get(ownerId) : null;
|
||||
g = u
|
||||
? group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username })
|
||||
: group("host", { kind: "host" });
|
||||
} else {
|
||||
g = group("system", { kind: "system" });
|
||||
}
|
||||
const cpu = (ticks / totalDelta) * 100;
|
||||
g.cpu += cpu;
|
||||
g.memory += p.rss;
|
||||
g.processes += 1;
|
||||
g.top.push({ pid, name: p.comm, cpu, memory: p.rss });
|
||||
})
|
||||
);
|
||||
|
||||
const list = [...groups.values()].map((g) => {
|
||||
g.top.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
|
||||
g.top = g.top.slice(0, TOP_PER_GROUP).map((t) => ({ ...t, cpu: round(t.cpu) }));
|
||||
return { ...g, cpu: round(g.cpu), memoryPercent: round((g.memory / mem.total) * 100) };
|
||||
});
|
||||
// Every user appears, idle ones with zeros, so the table is the whole roster.
|
||||
for (const u of users) {
|
||||
if (!groups.has(`user:${u.id}`))
|
||||
list.push({
|
||||
key: `user:${u.id}`,
|
||||
kind: "user",
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
memoryPercent: 0,
|
||||
processes: 0,
|
||||
top: [],
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
|
||||
return {
|
||||
cpu: round(((totalDelta - (t1.idle - t0.idle)) / totalDelta) * 100),
|
||||
sampleMs: SAMPLE_MS,
|
||||
groups: list,
|
||||
};
|
||||
}
|
||||
|
||||
const round = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
async function collect() {
|
||||
const linux = process.platform === "linux";
|
||||
const cpus = os.cpus();
|
||||
const [mem, diskList, gpuList, use] = await Promise.all([
|
||||
memory(),
|
||||
linux ? disks() : [],
|
||||
linux ? gpus() : [],
|
||||
linux ? usage() : null,
|
||||
]);
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
hostname: os.hostname(),
|
||||
os: {
|
||||
platform: process.platform,
|
||||
release: os.release(),
|
||||
distro: linux ? distro() : null,
|
||||
arch: os.arch(),
|
||||
},
|
||||
uptime: os.uptime(),
|
||||
cpu: { model: cpus[0]?.model?.trim() || null, cores: cpus.length, load: os.loadavg() },
|
||||
memory: mem,
|
||||
disks: diskList,
|
||||
homesMount: mountOf(diskList, deps.homesRoot()),
|
||||
gpus: gpuList,
|
||||
usage: use,
|
||||
};
|
||||
}
|
||||
|
||||
let cache = null; // { at, value }
|
||||
let inflight = null;
|
||||
|
||||
/** The current snapshot: cached for `FRESH_MS`, one sample shared by every caller. */
|
||||
async function hostStats() {
|
||||
if (cache && Date.now() - cache.at < FRESH_MS) return cache.value;
|
||||
if (!inflight) {
|
||||
inflight = collect()
|
||||
.then((value) => {
|
||||
cache = { at: Date.now(), value };
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
function __setDeps(over) {
|
||||
deps = { ...deps, ...over };
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { hostStats, mountOf, __setDeps };
|
||||
@@ -6,7 +6,7 @@
|
||||
* workspaces in.
|
||||
*
|
||||
* S3 is ON when server mode is on AND `/etc/agenthub/server.conf` exists
|
||||
* (written by `sudo agenthub server install-system`, root-owned). Without it
|
||||
* (written by `agenthub server install-system`, root-owned). Without it
|
||||
* everything here is a no-op and server mode behaves as in S2.
|
||||
*
|
||||
* `provision` is the dashboard's only way to create, disable or enable a Linux
|
||||
@@ -46,6 +46,13 @@ function serverConf() {
|
||||
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) {
|
||||
let text;
|
||||
try {
|
||||
@@ -56,16 +63,28 @@ function readConf(file) {
|
||||
const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || "";
|
||||
const usersRoot = get("USERS_ROOT");
|
||||
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 {
|
||||
hostUser: get("HOST_USER"),
|
||||
hostUid: Number(get("HOST_UID")),
|
||||
usersRoot,
|
||||
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
|
||||
// own secure_path, which may not be them).
|
||||
bins: Object.fromEntries(
|
||||
[
|
||||
["tmux", get("TMUX_BIN")],
|
||||
["tmux", tmuxBin],
|
||||
["git", get("GIT_BIN")],
|
||||
["gh", get("GH_BIN")],
|
||||
].filter(([, v]) => path.isAbsolute(v))
|
||||
@@ -139,7 +158,7 @@ function provision(action, username) {
|
||||
// An installed helper older than this build (root's copy is refreshed
|
||||
// only by install-system) does not know the action yet.
|
||||
const hint = /unknown action/.test(why)
|
||||
? " — the server helper is older than this AgentHub: run `sudo agenthub server install-system` once"
|
||||
? " — the server helper is older than this AgentHub: run `agenthub server install-system` once"
|
||||
: "";
|
||||
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}${hint}`), {
|
||||
code: "EPROVISION",
|
||||
@@ -154,6 +173,7 @@ function __resetConfCache() {
|
||||
|
||||
module.exports = {
|
||||
__resetConfCache,
|
||||
__setTmuxMember,
|
||||
confPath,
|
||||
LINUX_NAME_RE,
|
||||
serverConf,
|
||||
|
||||
@@ -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 };
|
||||
@@ -720,6 +720,53 @@ function killSession(name) {
|
||||
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. */
|
||||
function listSessions(prefix) {
|
||||
const names = [];
|
||||
@@ -867,6 +914,8 @@ module.exports = {
|
||||
upgradeAdvice,
|
||||
serverPid,
|
||||
memberAttach,
|
||||
memberServerInfo,
|
||||
killMemberServer,
|
||||
__setExecImpl,
|
||||
__setAccountImpl,
|
||||
__settle,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* - 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 /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
|
||||
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
||||
* @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": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -276,17 +276,23 @@ const paths = {
|
||||
"/api/me/git/ssh-key": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Make (or with `replace`, replace) the SSH key of `type`; the other type is kept",
|
||||
summary:
|
||||
"Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it",
|
||||
operationId: "generateSshKey",
|
||||
requestBody: json({
|
||||
replace: { type: "boolean" },
|
||||
type: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
|
||||
}),
|
||||
responses: { 201: { description: "The identity." } },
|
||||
responses: {
|
||||
201: { description: "The identity." },
|
||||
400: { description: "EBADKEYNAME" },
|
||||
409: { description: "EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS" },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Delete the SSH key of ?type=ed25519|rsa, or every key",
|
||||
summary: "Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key",
|
||||
operationId: "deleteSshKey",
|
||||
parameters: [
|
||||
{
|
||||
@@ -295,6 +301,7 @@ const paths = {
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
},
|
||||
{ name: "id", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: { 200: { description: "The identity." } },
|
||||
},
|
||||
@@ -311,6 +318,36 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-user": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
summary:
|
||||
"An SSH login: url.<ssh://user@host/>.insteadOf ssh://<alias or host>/, optionally tied to one key",
|
||||
operationId: "setSshUser",
|
||||
requestBody: json(
|
||||
{
|
||||
host: { type: "string" },
|
||||
user: { type: "string" },
|
||||
alias: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,62}$" },
|
||||
key: { type: "string" },
|
||||
},
|
||||
["host", "user"]
|
||||
),
|
||||
responses: {
|
||||
200: { description: "The identity; `sshUsers: [{host, user, alias, key}]`." },
|
||||
400: { description: "EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-user/{from}": {
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Drop the SSH login of a host or alias (and its key link)",
|
||||
operationId: "deleteSshUser",
|
||||
parameters: [{ name: "from", in: "path", required: true, schema: { type: "string" } }],
|
||||
responses: { 200: { description: "The identity." }, 404: { description: "ENOTFOUND" } },
|
||||
},
|
||||
},
|
||||
"/api/me/git/https": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`),
|
||||
* mounted at `/api/me/git`: the SSH keys a clone uses — ed25519 and/or rsa (make, replace, delete —
|
||||
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete —
|
||||
* `hasToken` only, never a password), whether the account's own terminal
|
||||
* git uses that key (`core.sshCommand`), the SSH login name per host
|
||||
* (`url.<…>.insteadOf` — AWS CodeCommit's SSH key ID), and the commit author (`user.name` /
|
||||
* mounted at `/api/me/git`: the SSH keys a clone uses — a default ed25519
|
||||
* and/or rsa one plus named ones (`name`, deleted by `?id=`) for more
|
||||
* accounts (make, replace, delete — only the PUBLIC key is ever returned) and
|
||||
* the HTTPS logins (save, delete — `hasToken` only, never a password),
|
||||
* whether the account's own terminal git uses the keys (`core.sshCommand`),
|
||||
* the SSH logins per host or alias (`url.<…>.insteadOf` — AWS CodeCommit's
|
||||
* SSH key ID — each optionally tied to one key), and the commit author (`user.name` /
|
||||
* `user.email` of the account's global git config — a member's commits fail
|
||||
* without one). The account is the signed-in user's
|
||||
* without one; a new SSH key is labelled with that email, else
|
||||
* `<username>@smartgift.vn`). The account is the signed-in user's
|
||||
* own Linux account under S3, else the host's, which only an admin (or local
|
||||
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
|
||||
* request, and every write passes the same-origin guard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const express = require("express");
|
||||
|
||||
const identity = require("../lib/git-identity");
|
||||
@@ -31,6 +35,10 @@ const STATUS = {
|
||||
EBADKEYTYPE: 400,
|
||||
EFOREIGNSSH: 409,
|
||||
EBADSSHUSER: 400,
|
||||
EBADKEYNAME: 400,
|
||||
ETOOMANYKEYS: 409,
|
||||
EBADALIAS: 400,
|
||||
ENOKEY: 400,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -39,7 +47,7 @@ function fail(res, e) {
|
||||
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
async function view(account) {
|
||||
async function view(account, username) {
|
||||
const keys = identity.sshKeysInfo(account);
|
||||
const terminal = keys.length ? await identity.terminalSshState(account) : undefined;
|
||||
return {
|
||||
@@ -52,9 +60,14 @@ async function view(account) {
|
||||
sshUsers: await identity.listSshUsers(account),
|
||||
https: identity.listHttps(account),
|
||||
author: await identity.getAuthor(account),
|
||||
// What the author email form suggests while none is set.
|
||||
defaultEmail: identity.defaultEmail(username),
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in user's name, or the OS user's in local mode. */
|
||||
const usernameOf = (req) => req.user?.username || os.userInfo().username;
|
||||
|
||||
/** The request's account; a write to the host's identity needs an admin. */
|
||||
function accountOf(req, { write = false } = {}) {
|
||||
const account = identity.accountForRequest(req);
|
||||
@@ -68,7 +81,7 @@ function accountOf(req, { write = false } = {}) {
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
res.json(await view(accountOf(req)));
|
||||
res.json(await view(accountOf(req), usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -80,9 +93,12 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
await identity.generateSshKey(account, {
|
||||
replace: req.body?.replace === true,
|
||||
type: req.body?.type === "rsa" ? "rsa" : "ed25519",
|
||||
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
|
||||
// A named key sits beside the default one of its type.
|
||||
name: typeof req.body?.name === "string" ? req.body.name : null,
|
||||
// The key is labelled with the commit author's email, else the default one.
|
||||
comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
|
||||
});
|
||||
res.status(201).json(await view(account));
|
||||
res.status(201).json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -92,10 +108,11 @@ router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
const type = req.query.type === undefined ? undefined : String(req.query.type);
|
||||
if (!(await identity.removeSshKey(account, type))) {
|
||||
const id = req.query.id === undefined ? undefined : String(req.query.id);
|
||||
if (!(await identity.removeSshKey(account, { type, id }))) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -112,21 +129,23 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
await identity.setTerminalSsh(account, req.body?.on === true);
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The SSH user name for one host (`{host, user}`) — e.g. AWS CodeCommit's SSH
|
||||
* key ID, so a plain `ssh://git-codecommit…/` URL logs in. DELETE drops it.
|
||||
* An SSH login (`{host, user, alias?, key?}`) — e.g. AWS CodeCommit's SSH key
|
||||
* ID, so a plain `ssh://git-codecommit…/` URL logs in; with `alias`, one more
|
||||
* account on that host (`ssh://<alias>/…`); with `key`, the only key it uses.
|
||||
* DELETE `/ssh-user/<host or alias>` drops it.
|
||||
*/
|
||||
router.put("/ssh-user", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
await identity.setSshUser(account, req.body || {});
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -138,7 +157,7 @@ router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
|
||||
if (!(await identity.removeSshUser(account, req.params.host))) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -149,7 +168,7 @@ router.post("/https", sameOriginGuard, async (req, res) => {
|
||||
const account = accountOf(req, { write: true });
|
||||
const { host, username, password } = req.body || {};
|
||||
identity.saveHttps(account, { protocol: "https", host, username, password });
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -161,7 +180,7 @@ router.delete("/https/:host", sameOriginGuard, async (req, res) => {
|
||||
if (!identity.removeHttps(account, "https", req.params.host)) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such login" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -172,7 +191,7 @@ router.put("/author", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
await identity.setAuthor(account, req.body || {});
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
|
||||
+69
-11
@@ -4,13 +4,18 @@
|
||||
* `inHome` so the dot entries at the top of a home stay out of reach).
|
||||
*
|
||||
* GET /api/homes the homes the caller may open
|
||||
* GET /api/homes/trash every Trash the caller may open, merged
|
||||
* GET /api/homes/:who/fs/list|trash|zip|download
|
||||
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||
*
|
||||
* `:who` is `me`, or — for an admin — another user's name: read-only
|
||||
* (`403 EREADONLY` on every write) and written to the audit log. Writes are
|
||||
* same-origin; a copy may come from a workspace (`fromLane`) the caller can
|
||||
* see, of the same Linux account.
|
||||
* (`403 EREADONLY` on every write) and written to the audit log. The one
|
||||
* exception is that user's TRASH: an admin lists, restores and purges it
|
||||
* (the recycle-bin management in /files), each write audited with the item
|
||||
* id. A home's Trash is its USER's whole recycle bin (`fx.listUserTrash`:
|
||||
* their home account's trash plus their host trash), each item tagged with
|
||||
* the `store` a restore/purge names. Writes are same-origin; a copy may come
|
||||
* from a workspace (`fromLane`) the caller can see, of the same Linux account.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -57,12 +62,12 @@ const paths = (body) =>
|
||||
* Resolve `:who`, refuse a write to someone else's home, audit an admin's
|
||||
* read of it, then run `fn(home)` inside the home rule.
|
||||
*/
|
||||
function handle(fn, { write = false } = {}) {
|
||||
function handle(fn, { write = false, trash = false } = {}) {
|
||||
return async (req, res) => {
|
||||
let home;
|
||||
try {
|
||||
home = homes.resolveHome(req, req.params.who);
|
||||
if (write && home.readOnly) {
|
||||
if (write && !trash && home.readOnly) {
|
||||
throw Object.assign(new Error("another user's home is read-only"), { code: "EREADONLY" });
|
||||
}
|
||||
if (home.readOnly && req.user) {
|
||||
@@ -70,7 +75,7 @@ function handle(fn, { write = false } = {}) {
|
||||
req.user.id,
|
||||
home.ownerId,
|
||||
req.method,
|
||||
`home ${req.params.who}: ${req.path} ${String(req.query.path ?? "")}`
|
||||
`home ${req.params.who}: ${req.path} ${String(req.query.path ?? req.body?.id ?? "")}`
|
||||
);
|
||||
}
|
||||
await fx.inHome(() => fn(req, res, home));
|
||||
@@ -81,11 +86,51 @@ function handle(fn, { write = false } = {}) {
|
||||
};
|
||||
}
|
||||
const trashFor = (req, home) => (rel) => fx.trashOne(home.dir, null, rel, userKey(req));
|
||||
/** Whose trash a home's Trash view shows: the home OWNER's (an admin looking
|
||||
* at another user's home manages THEIR recycle bin, not the admin's own). */
|
||||
const trashKey = (req, home) =>
|
||||
home.readOnly && home.ownerId != null ? String(home.ownerId) : userKey(req);
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
res.json({ homes: homes.listHomes(req) });
|
||||
});
|
||||
|
||||
/**
|
||||
* Every recycle bin the caller may open in one list — an admin's is every
|
||||
* user's — each item tagged with its home (`home`, the `:who` a restore or
|
||||
* purge goes to) and that user's name (`owner`). A home whose trash cannot
|
||||
* be read is reported in `errors`, never silently left out. Each other
|
||||
* user's trash read is audited like opening their home.
|
||||
*/
|
||||
router.get("/trash", async (req, res) => {
|
||||
const items = [];
|
||||
const errors = [];
|
||||
let keepDays;
|
||||
for (const h of homes.listHomes(req)) {
|
||||
try {
|
||||
const home = homes.resolveHome(req, h.id);
|
||||
if (home.readOnly && req.user) {
|
||||
require("../lib/audit").recordHomeRead(
|
||||
req.user.id,
|
||||
home.ownerId,
|
||||
"GET",
|
||||
`home ${h.id}: /trash (all trashes)`
|
||||
);
|
||||
}
|
||||
const out = await fx.listUserTrash(home.dir, trashKey(req, home));
|
||||
keepDays = out.keepDays;
|
||||
for (const i of out.items) {
|
||||
if (i.laneId != null && !scope.canSeeLane(req, i.laneId)) continue;
|
||||
items.push({ ...i, home: h.id, owner: h.label });
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ home: h.id, owner: h.label, message: err.message });
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
res.json({ items, errors, keepDays });
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/:who/fs/list",
|
||||
handle(async (req, res, home) => {
|
||||
@@ -136,13 +181,15 @@ router.get(
|
||||
router.get(
|
||||
"/:who/fs/trash",
|
||||
handle(async (req, res, home) => {
|
||||
const out = await fx.listTrash(home.dir, userKey(req));
|
||||
const out = await fx.listUserTrash(home.dir, trashKey(req, home));
|
||||
out.items = out.items.filter((i) => i.laneId == null || scope.canSeeLane(req, i.laneId));
|
||||
res.json(out);
|
||||
})
|
||||
);
|
||||
|
||||
const W = { write: true };
|
||||
// Restore/purge: writes, but allowed in another user's home for an admin.
|
||||
const TRASH_W = { write: true, trash: true };
|
||||
router.post(
|
||||
"/:who/fs/mkdir",
|
||||
sameOriginGuard,
|
||||
@@ -223,15 +270,26 @@ router.post(
|
||||
const l = lanesLib.getLane(Number(laneId));
|
||||
return l && scope.canSeeLane(req, l.id) ? l.cwd : null;
|
||||
};
|
||||
res.json(await fx.restore(home.dir, req.body?.id, userKey(req), resolveLane));
|
||||
}, W)
|
||||
res.json(
|
||||
await fx.restoreUserTrash(
|
||||
home.dir,
|
||||
trashKey(req, home),
|
||||
req.body?.store,
|
||||
req.body?.id,
|
||||
resolveLane
|
||||
)
|
||||
);
|
||||
}, TRASH_W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/trash/purge",
|
||||
sameOriginGuard,
|
||||
handle(
|
||||
async (req, res, home) => res.json(await fx.purgeTrash(home.dir, userKey(req), req.body?.id)),
|
||||
W
|
||||
async (req, res, home) =>
|
||||
res.json(
|
||||
await fx.purgeUserTrash(home.dir, trashKey(req, home), req.body?.store, req.body?.id)
|
||||
),
|
||||
TRASH_W
|
||||
)
|
||||
);
|
||||
router.post(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @file host.js
|
||||
* @description `GET /api/host` — the Administration page's Server tab: the
|
||||
* machine's hardware, load and disks, and each AgentHub user's share of its
|
||||
* CPU and memory right now (server/lib/host-stats.js). Read-only; staff only
|
||||
* in server mode (`/host` is in STAFF_ONLY_PREFIXES, server/lib/auth.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { hostStats } = require("../lib/host-stats");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
try {
|
||||
res.json(await hostStats());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: { code: "EHOST", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -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