Compare commits

...

16 Commits

Author SHA1 Message Date
nntrivi2001 a58e95a957 chore: v2.8.7 — Git SSH keys in tabs, safer Artifacts preview and search
- Settings → Git → Your git identity: SSH keys are one tab per host kind
  (GitHub · GitLab, AWS CodeCommit, More keys, Other hosts); opens on the
  AWS tab when only an RSA key exists.
- Artifacts: a file type the sandboxed preview cannot show (Office, zip,
  PDF) offers Download only, never a blank Preview.
- Artifacts search: `%`/`_`/`\` in a query match literally (LIKE ... ESCAPE).
- Skill frontmatter rewrite replaces a key with its continuation lines and
  quotes values YAML could misread.
- Plugin marketplace.json is read with a 1 MB streaming cap.
- Artifact lists look each owner up once.
2026-10-15 17:17:47 +07:00
nntrivi2001 4d6067650a chore: v2.8.6 — Administration page (Users + Server tabs), right-click menus, several SSH keys per host 2026-10-15 16:45:24 +07:00
nntrivi2001 5bc3f7d34a Merge branch 'feat/admin-console': Administration page (Users + Server tabs) 2026-10-15 16:44:09 +07:00
nntrivi2001 b058179177 feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host
- Files: right-click a sidebar workspace (Open, Copy path) or home (Open,
  Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path),
  a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh,
  Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash).
- Users: row menu (Edit + Open home in Files for an admin, Copy username/email).
- Artifacts: card menu repeating its buttons, plus Copy link for a page.
- Git identity: named SSH keys beside the default ones (id_<type>_<name>,
  max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` →
  `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host),
  each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of
  that URL offers only that key and persists it. Terminal sshCommand follows
  the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and
  other-host forms. Docs/API.md, OpenAPI updated.
2026-10-15 16:42:47 +07:00
nntrivi2001 b6b40b0c1b feat(admin): Administration page — Users tab + Server tab (hardware, per-user CPU/memory/home share)
/users becomes /admin (redirected), a tab shell over the existing Users view
and a new Server tab backed by GET /api/host (server/lib/host-stats.js):
hostname/OS/uptime, CPU and load, memory and swap, every real disk with
HDD/SSD, NVIDIA GPUs, and each user's live share from two /proc samples —
attributed by the member's Linux uid (S3) or, for host processes, by the
inherited AGENTHUB_LANE_ID's workspace owner. Read-only, staff-only (/host
in STAFF_ONLY_PREFIXES), comm names only. Members' tmux card moves to the
Server tab.
2026-10-15 16:33:32 +07:00
nntrivi2001 758609775f chore: v2.8.5 — Trash opens on one folder per user, server setup installs S3
- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's
  Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable
  one showing its error); a folder opens that user's Trash (path bar
  Trash › user, Up back to the folders). An admin may restore/purge in
  another user's Trash (audited); purging asks first. The merged "All users"
  list is gone from the UI.
- A home's Trash is its user's whole recycle bin (home store + host store,
  items tagged `store`).
- `agenthub server setup` installs one Linux user per account on Linux by
  default (`--no-system` opts out); install-system messages drop `sudo`.
- Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:02:11 +07:00
nntrivi2001 09efb089cf chore: v2.8.4 — member tmux follows tmux upgrades, Members' tmux card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:45:24 +07:00
nntrivi2001 c83ddf62a0 feat(server-s3): a member's tmux follows tmux upgrades — tmux-member + Users → Members' tmux
A member's tmux server keeps the version it started with until their last
pane closes, but the dashboard asked it with TMUX_BIN, the newest tmux: a
3.8 client on a 3.6 server spun it at 100% CPU (three members needed a
manual kill on 2026-10-15).

- /opt/agenthub/libexec/tmux-member (install-system, in sudoers): runs a
  member's tmux call with the binary of THEIR running server
  (/proc/<pid>/exe, found from the socket in /proc/net/unix), else
  TMUX_BIN. It runs as the member and execs only a process they own.
  sudoArgv uses it once installed (linux-accounts readConf), TMUX_BIN
  until then.
- Users → Members' tmux (GET /api/users/tmux): each member server's version
  beside the installed one, sessions, agents working. An admin restarts one
  (POST /api/users/:id/tmux-restart) or every idle outdated one
  (POST /api/users/tmux-restart-idle), with their password again, audited
  per member; a server that will not stop answers 409 ETMUXSTUCK naming
  its pid for root.
- server-s3-system.test.js: `delete` is a provision action since Delete
  user, so the "unknown action" case now uses `purge`.

Operators: run `sudo agenthub server install-system` once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:44:47 +07:00
nntrivi2001 d8ab850295 chore: v2.8.3 — a stuck tmux server no longer freezes the dashboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:46 +07:00
nntrivi2001 bac6c04a0b fix(tmux): bound calls on our own server and run them with the server's own binary
A tmux 3.8 installed under a running 3.6 server left PATH's 3.8 client
asking the 3.6 server, which spun at 100% CPU; the dashboard's
synchronous `has-session` on it had no limit and froze every page, hook
and terminal for five minutes (14:07–14:12 on the shared server).

- Every call on our own server now has a 5 s limit and the member's
  one-minute back-off (`host: tmux server not answering` meanwhile), so a
  stuck server costs the event loop one timeout per minute, not one per
  poll.
- Every call on a running server runs that server's own binary
  (`/proc/<pid>/exe`, found from its listening socket in /proc — asking
  tmux would be the very call that hangs), re-read every 10 s; PATH's
  tmux only starts a server or answers `-V`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:16 +07:00
nntrivi2001 38c7ac6635 chore: v2.8.2 — SSH key sections per git host, CodeCommit SSH key ID for a plain ssh:// clone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:53:32 +07:00
nntrivi2001 276cead826 feat(git identity): one section per git host; CodeCommit SSH key ID makes a plain ssh:// clone work
AWS CodeCommit logs an SSH key in by its IAM SSH key ID (APKA…), so a
plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/x`
was refused as the Linux account name. The account can now save an SSH
user per host — PUT/DELETE /api/me/git/ssh-user, listed as `sshUsers` —
written as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in its
own ~/.gitconfig (as the member under S3), so the plain URL works in a
pane, in Clone and in the git bar. Only rules of exactly that shape are
listed or removed; any other insteadOf is the user's and left alone.

Settings → Git → Your git identity now shows the SSH keys one section
per kind of host, each with only its own numbered steps: GitHub · GitLab
· Gitea · Bitbucket (ED25519) and AWS CodeCommit (RSA: copy → upload in
IAM → paste the key ID and pick the region, then a "Ready" clone line),
plus a collapsed "Other SSH hosts (advanced)". The shared key-type
picker is gone: each section has its own Generate/Replace/Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:31:30 +07:00
nntrivi2001 1c5bd88747 chore: v2.8.1 — Stop archives / Sleep keeps a session, SSH key per type (#82), #83 tables
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:52:32 +07:00
nntrivi2001 0a1dcfeb7f feat: Stop archives a session, Sleep keeps it; SSH keys per type (#82); update Auto fix
- Stop (a tab's or pane's X, Close tab) now archives the session it held
  (DELETE /api/run/:id?archive=1); the new Sleep (moon left of the X, and in
  the tab, pane and session right-click menus) stops the pane but keeps the
  session listed to resume later. Shared endRuns in client/src/lib/stopSession.ts.
- Git identity (#82): an account keeps an ED25519 AND an RSA key (AWS
  CodeCommit takes only RSA), every ssh git offers both, and a member's
  ~/.gitconfig gets core.sshCommand so a git typed in a terminal uses them.
- Update dialog: Auto fix starts a Claude Code session on the install
  folder with the failed update's error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:51:30 +07:00
nntrivi2001 58be04a4e7 fix(chat): render a markdown table that follows prose with no blank line (#83)
The chat renderer's paragraph collector never stopped at a table header,
so a table right after a line of text was shown as raw pipes. A table now
interrupts a paragraph as in GFM, and the divider accepts single dashes.

Closes #83

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:51:30 +07:00
Nguyễn Ngọc Trí Vĩ cd427d218b fix(terminal): psmux wheel scrolling, per-platform tmux upgrade advice, Windows-safe server tests
- Server tests on Windows no longer reach the user's real psmux: psmux
  ignores TMUX_TMPDIR (and 3.3.x PSMUX_DATA_DIR), so quit.test.js's Quit and
  the runner's kill-server ended every live lane pane — the pre-commit hook
  included. scripts/test-server.js now takes tmux off the suites' PATH there.
- Wheel scrolling on Windows: psmux never turns mouse tracking on for its
  client, so xterm.js never reported the wheel and a pane could not scroll.
  A Windows attach is told {type:"wheel"} and the browser sends the SGR wheel
  report itself; psmux scrolls or forwards it to the pane's app.
- tmux.upgradeAdvice(): the oldest tmux AgentHub asks for and how each
  platform upgrades (psmux 3.4.0 -> winget upgrade marlocarlo.psmux; tmux 3.7
  -> brew upgrade tmux / build the release on Linux). Used by the doctor,
  GET /api/run/tmux ({available, version, upgrade}), `agenthub doctor`, and a
  sticky toast on the Workspace page. Advice only: nothing installs tmux.

Committed with --no-verify (user's call): the pre-commit hook runs the full
backend suite, which has ~378 pre-existing failures on Windows (S3/Linux-only
tests: sudo, /proc, systemd, members). Every test touching this change passes:
pty-attach, tmux, run, plugin-doctor-tmux, TerminalView, Workspace, and the
client tests related to the changed files; tsc and the header audit are clean.
2026-10-15 11:46:09 +07:00
91 changed files with 6927 additions and 600 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+64 -10
View File
@@ -1284,12 +1284,14 @@ async function cmdDoctor() {
// warning, not a failure: it costs the run console and `lanes shell`, nothing
// else.
try {
const { available } = await get("/api/run/tmux");
const { available, upgrade } = await get("/api/run/tmux");
console.log(
available
? `${c.green("✔")} tmux available to the server`
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
!available
? `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
: upgrade
? `${c.yellow("!")} tmux ${upgrade}`
: `${c.green("✔")} tmux available to the server`
);
} catch {
console.log(`${c.dim("·")} tmux not reported by this server`);
@@ -3932,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>")
);
@@ -3987,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
View File
@@ -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={
+3 -3
View File
@@ -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);
+92 -15
View File
@@ -16,9 +16,13 @@
* — and the poll waits for the old server to actually go DOWN first, because it
* keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a
* background process can do for it. Escape, the backdrop and "Not now" close it
* for this page load only (never while applying); "Skip this version" is the
* one persistent, per-version dismiss.
* background process can do for it. Escape, the backdrop and × close it for
* this page load only (never while applying); "Skip this version" is the one
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
* and runs the update command; the dashboard itself still decides nothing.
*
* ## State sources
* - Initial fetch via `api.updates.status()` on mount.
@@ -81,7 +85,18 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
Download,
X,
Copy,
Check,
RefreshCw,
Zap,
ExternalLink,
SkipForward,
Wrench,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
@@ -89,6 +104,7 @@ import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { usePermissions } from "./AuthGate";
import { startInFreeTab } from "./lanes/startSession";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
@@ -107,6 +123,32 @@ function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean
);
}
/** The prompt the Auto fix session starts with: what failed, where, and the
* limits that keep the agent from taking down the dashboard it runs under. */
function autoFixPrompt(status: UpdateStatusPayload, error: string): string {
const target = status.latest_release || status.remote_ref || "the newest version";
const plugin = status.situation === "plugin_managed";
return [
`The AgentHub dashboard's self-update to ${target} failed${status.local_version ? ` (running ${status.local_version})` : ""}.`,
plugin
? "AgentHub is installed as a Claude Code plugin (the `agenthub` plugin from the `agenthub` marketplace)."
: `The install is the git checkout at ${status.repo_root}.`,
"",
"Error:",
"```",
error,
"```",
...(status.manual_command ? ["", "Update command:", "```", status.manual_command, "```"] : []),
"",
"Find the cause, fix it, then finish the update" +
(plugin
? " (`claude plugin marketplace update agenthub` then `claude plugin update agenthub@agenthub`)."
: " by running the update command."),
"Rules: never lose the user's work — before discarding any local change, save it (e.g. `git diff > ~/agenthub-update-backup.patch`) and say where; do not stop or restart the AgentHub server, and never kill tmux sessions or the tmux server (this session runs inside one).",
"When done, tell me what you changed; I will restart the dashboard from its update prompt.",
].join("\n");
}
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
@@ -130,7 +172,7 @@ export function UpdateNotifier() {
const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
// Closed for THIS page load only (Escape, backdrop, ×) — the
// persistent per-version dismiss is the explicit "Skip this version".
const [closedKey, setClosedKey] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
@@ -139,6 +181,8 @@ export function UpdateNotifier() {
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const [fixing, setFixing] = useState(false);
const navigate = useNavigate();
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
setStatus(s);
@@ -200,7 +244,7 @@ export function UpdateNotifier() {
dismissedSha !== dismissKey &&
closedKey !== dismissKey
);
const busy = applying || restarting;
const busy = applying || restarting || fixing;
const closeForNow = useCallback(() => {
if (busy || !dismissKey) return;
@@ -317,6 +361,29 @@ export function UpdateNotifier() {
}
};
// Hand the failure to an AI agent: a workspace on the install folder (the
// home for a plugin install — never a lane inside the plugin cache) and a
// Claude Code session there with the error as its first prompt.
const autoFix = async () => {
if (busy || !status || !error) return;
setFixing(true);
try {
const plugin = status.situation === "plugin_managed";
const { lane } = await api.lanes.ensure({
...(plugin || !status.repo_root ? {} : { cwd: status.repo_root, exact: true }),
title: t("autoFixWorkspace"),
tool: "claude",
});
const runId = await startInFreeTab(lane.id, { prompt: autoFixPrompt(status, error) });
if (dismissKey) setClosedKey(dismissKey);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : t("autoFixError"));
} finally {
setFixing(false);
}
};
if (!show || !status) return null;
const refLabel = status.remote_ref || "origin";
@@ -493,16 +560,9 @@ export function UpdateNotifier() {
title={t("skipVersionHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
<SkipForward className="w-3.5 h-3.5" aria-hidden />
{t("skipVersion")}
</button>
<button
type="button"
onClick={closeForNow}
disabled={busy}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("later")}
</button>
{status.manual_command ? (
<button
type="button"
@@ -514,11 +574,28 @@ export function UpdateNotifier() {
{copied ? t("copied") : t("copy")}
</button>
) : null}
{/* Only after a failed update: an AI agent takes the error from here. */}
{error ? (
<button
type="button"
onClick={autoFix}
disabled={busy}
title={t("autoFixHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{fixing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden />
) : (
<Wrench className="w-3.5 h-3.5" aria-hidden />
)}
{t("autoFix")}
</button>
) : null}
{isAutoApplicable(status.situation) ? (
<button
type="button"
onClick={applyNow}
disabled={applying || restarting}
disabled={busy}
className="btn-primary disabled:opacity-70"
>
{applying || restarting ? (
@@ -19,6 +19,18 @@ import type { UpdateStatusPayload } from "../../lib/types";
const statusMock = vi.fn();
const checkMock = vi.fn();
const applyMock = vi.fn();
const ensureMock = vi.fn();
const startMock = vi.fn();
const navigateMock = vi.fn();
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useNavigate: () => navigateMock,
}));
vi.mock("../lanes/startSession", () => ({
startInFreeTab: (...args: unknown[]) => startMock(...args),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -30,6 +42,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
check: (...args: unknown[]) => checkMock(...args),
apply: (...args: unknown[]) => applyMock(...args),
},
lanes: { ensure: (...args: unknown[]) => ensureMock(...args) },
},
};
});
@@ -56,6 +69,9 @@ beforeEach(() => {
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
applyMock.mockReset();
ensureMock.mockReset().mockResolvedValue({ lane: { id: 7 }, created: true });
startMock.mockReset().mockResolvedValue("agenthub-lane-7-1");
navigateMock.mockReset();
try {
localStorage.clear();
} catch {
@@ -274,7 +290,7 @@ describe("UpdateNotifier — closing vs skipping", () => {
it("Check for updates re-opens a modal closed with Later", async () => {
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
await userEvent.click(await screen.findByRole("button", { name: i18n.t("updates:later") }));
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
@@ -290,3 +306,26 @@ describe("UpdateNotifier — closing vs skipping", () => {
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
});
});
describe("UpdateNotifier — Auto fix", () => {
it("offers no Not now button, and Auto fix only once an update failed", async () => {
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:updateNow"));
expect(screen.queryByText(i18n.t("updates:later"))).not.toBeInTheDocument();
expect(screen.queryByText(i18n.t("updates:autoFix"))).not.toBeInTheDocument();
await userEvent.click(screen.getByText(i18n.t("updates:updateNow")));
await userEvent.click(await screen.findByText(i18n.t("updates:autoFix")));
await waitFor(() => expect(navigateMock).toHaveBeenCalled());
expect(ensureMock).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/repo", exact: true, tool: "claude" })
);
const [laneId, body] = startMock.mock.calls[0] ?? [];
expect(laneId).toBe(7);
expect(body.prompt).toContain("git merge --ff-only v2.8.0");
expect(body.prompt).toContain(BASE_STATUS.manual_command);
expect(navigateMock).toHaveBeenCalledWith("/workspace?lane=7&run=agenthub-lane-7-1");
});
});
@@ -254,7 +254,7 @@ describe("WorkspaceTree", () => {
let menu = screen.getByTestId("session-menu-s-old");
// Ended: no pane to split or close.
expect(within(menu).queryByText("Open in split")).toBeNull();
expect(within(menu).queryByText("Close tab (stops its session)")).toBeNull();
expect(within(menu).queryByText("Close tab (stops and archives its session)")).toBeNull();
await user.click(within(menu).getByText("Rename"));
expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
expect(screen.getByTestId("workspace-session-rename-s-old")).toBeInTheDocument();
@@ -271,11 +271,17 @@ describe("WorkspaceTree", () => {
await user.click(within(screen.getByTestId("session-menu-s-live")).getByText("Archive"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
await right(screen.getByTestId("workspace-session-s-live"));
await user.click(screen.getByTestId("session-menu-sleep"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: false });
await right(screen.getByTestId("workspace-session-s-live"));
await user.click(
within(screen.getByTestId("session-menu-s-live")).getByText("Close tab (stops its session)")
within(screen.getByTestId("session-menu-s-live")).getByText(
"Close tab (stops and archives its session)"
)
);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
});
it("archiving a worktree session removes its worktree first, after asking", async () => {
+417
View File
@@ -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>
);
}
@@ -84,7 +84,12 @@ const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
const QUOTE_RE = /^\s*>\s?(.*)$/;
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
/** A GFM table starts at a line holding a pipe that is followed by a divider. */
function isTableStart(lines: string[], i: number): boolean {
return lines[i]!.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!);
}
function splitTableRow(line: string): string[] {
// Trim leading/trailing pipes, then split, respecting escaped pipes.
@@ -171,7 +176,7 @@ function parseBlocks(src: string): Block[] {
}
// Table: header line followed by an alignment divider
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) {
if (isTableStart(lines, i)) {
const header = splitTableRow(line);
const aligns = parseAlignments(lines[i + 1]!);
i += 2;
@@ -227,7 +232,8 @@ function parseBlocks(src: string): Block[] {
continue;
}
// Paragraph: collect until a blank line or the start of another block
// Paragraph: collect until a blank line or the start of another block —
// a table included, which (as in GFM) may follow prose with no blank line.
const paraLines: string[] = [line];
i++;
while (i < lines.length) {
@@ -239,7 +245,8 @@ function parseBlocks(src: string): Block[] {
HR_RE.test(nl) ||
UL_RE.test(nl) ||
OL_RE.test(nl) ||
QUOTE_RE.test(nl)
QUOTE_RE.test(nl) ||
isTableStart(lines, i)
) {
break;
}
@@ -300,7 +307,10 @@ function UrlLink({ url }: { url: string }) {
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
function decodeHref(href: string): string {
let p = href.replace(/^file:\/\//i, "").replace(/#L(\d+)(?:-L?\d+)?$/, ":$1").replace(/#.*$/, "");
let p = href
.replace(/^file:\/\//i, "")
.replace(/#L(\d+)(?:-L?\d+)?$/, ":$1")
.replace(/#.*$/, "");
try {
p = decodeURIComponent(p);
} catch {
@@ -383,13 +393,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
{codeM[1]}
</code>
);
push(
matchPath(codeM[1]!) === codeM[1] ? (
<PathLink path={codeM[1]!}>{code}</PathLink>
) : (
code
)
);
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
i += codeM[0].length;
continue;
}
@@ -440,9 +444,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
) {
push(
<PathLink path={decodeHref(href)}>
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
</PathLink>
<PathLink path={decodeHref(href)}>{renderInline(linkM[1]!, `${baseKey}-l${n}`)}</PathLink>
);
i += linkM[0].length;
continue;
@@ -153,4 +153,18 @@ describe("<MarkdownContent />", () => {
expect(container.firstChild).not.toBeNull();
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
});
it("renders a table that follows prose with no blank line (issue #83)", () => {
const text = 'Cách kiểm tra:\n| Token xoá | Số chỗ |\n|---|---|\n| `width="N"` | 29 |';
const { container } = render(<MarkdownContent text={text} />);
expect(container.querySelector("p")?.textContent).toBe("Cách kiểm tra:");
expect(container.querySelectorAll("th").length).toBe(2);
expect(container.querySelectorAll("tbody tr").length).toBe(1);
expect(container.textContent).not.toContain("|---|");
});
it("accepts a GFM divider with single dashes", () => {
const { container } = render(<MarkdownContent text={"| a | b |\n|-|:-:|\n| 1 | 2 |"} />);
expect(container.querySelectorAll("th").length).toBe(2);
});
});
+390 -22
View File
@@ -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>
);
}
+16 -22
View File
@@ -5,19 +5,20 @@
* does: open it the way its click does (a live pane attaches, an ended session
* opens its conversation — never a resume), open a live one in a split,
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
* its analysis page, and end its live tab as the tab's buttons do (`endRuns`,
* after the same confirm for an AI tab, which may be mid-turn): **Sleep** stops
* it and keeps the session listed to resume later, **Close tab** stops it and
* archives the session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import type { RunHandle } from "../../lib/api";
import type { Session } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { canSleep, endRuns } from "../../lib/stopSession";
export function SessionMenu({
laneId,
@@ -50,24 +51,12 @@ export function SessionMenu({
onClose();
fn();
};
const closeTab = async () => {
// Stop archives the session; Sleep keeps it listed to resume later
// (`endRuns` asks first for an AI pane, which may be mid-turn).
const endTab = (sleep: boolean) => {
if (!run) return;
onClose();
if (
run.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: session?.name || t("tree.newSession") }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(run.id);
} catch (err) {
toast.error(err);
}
void endRuns([run], { sleep, name: session?.name || t("tree.newSession"), t });
};
return (
@@ -105,8 +94,13 @@ export function SessionMenu({
{(run || onArchive || onUnarchive) && <MenuSeparator />}
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
{canSleep(run) && (
<MenuItem testId="session-menu-sleep" onSelect={() => endTab(true)}>
{t("tabs.sleep")}
</MenuItem>
)}
{run && (
<MenuItem danger onSelect={() => void closeTab()}>
<MenuItem danger onSelect={() => endTab(false)}>
{t("tabs.close")}
</MenuItem>
)}
@@ -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();
+33 -28
View File
@@ -8,8 +8,11 @@
* prompt it started on, else its tool and number; a combo tab shows every
* pane's icon and name. A tab carries a state dot (waiting for you / error /
* running — a combo shows the most urgent, with screen-reader text). A tab's
* X stops every session in it (`DELETE /api/run/:id`) — after a confirm when
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
* X stops every session in it AND archives them (`endRuns`,
* `DELETE /api/run/:id?archive=1`); the moon beside it (and the tab menu's
* Sleep) stops its AI sessions but keeps them listed, to resume later. Either
* asks first when one is an AI tool, since it may be mid-turn; a shell closes
* at once. Arrow
* keys, Home and End move between tabs (one tab stop, APG). Errors are
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
@@ -48,13 +51,13 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
import { House, Moon, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
import { api } from "../../lib/api";
import { useSettingsHref } from "../../lib/settingsLink";
import type { RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { canSleep, endRuns } from "../../lib/stopSession";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools";
@@ -432,30 +435,10 @@ export function ConsoleTabs({
}
};
// An AI session may be mid-turn: stopping a tab holding one asks first. A
// shell holds no agent.
const close = async (tab: Tab) => {
if (
tab.runs.some((r) => r.tool !== "shell") &&
!(await confirmDialog({
title:
tab.runs.length > 1
? t("tabs.stopGroupTitle", { count: tab.runs.length })
: t("tabs.stopTitle", { name: label(tab.focus) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
for (const r of tab.runs) {
try {
await api.run.kill(r.id);
onClosed(r.id);
} catch (err) {
toast.error(err);
}
}
// Stop archives the tab's sessions; Sleep keeps them listed to resume
// (`endRuns` asks first for an AI pane, which may be mid-turn).
const close = async (tab: Tab, sleep = false) => {
for (const id of await endRuns(tab.runs, { sleep, name: label(tab.focus), t })) onClosed(id);
};
const startRename = (r: RunHandle) => {
@@ -638,6 +621,20 @@ export function ConsoleTabs({
<span className="truncate">{tabLabel(tab)}</span>
{state && <RunDot state={state} />}
</button>
{tab.runs.some(canSleep) && (
<button
type="button"
data-testid={`console-tab-sleep-${tab.key}`}
onClick={() => void close(tab, true)}
aria-label={t("tabs.sleep")}
title={t("tabs.sleep")}
className={`shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
}`}
>
<Moon className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
data-testid={`console-tab-close-${tab.key}`}
@@ -785,6 +782,14 @@ export function ConsoleTabs({
</>
)}
<MenuSeparator />
{ctxTab.runs.some(canSleep) && (
<MenuItem
testId="console-tab-menu-sleep"
onSelect={pickTab((tab) => void close(tab, true))}
>
{t("tabs.sleep")}
</MenuItem>
)}
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
{t("tabs.close")}
</MenuItem>
+26 -2
View File
@@ -10,7 +10,9 @@
* there, on its middle to swap), pick another live session for it, split it
* right or down, or take the pane OUT of this tab (its session keeps running
* in a tab of its own — so its icon is not the tab's stop X), or stop its
* session there (X, `onStop`) without taking it out first. The grid shows
* session there (X, `onStop`, which archives it) or put an AI session to
* sleep (the moon left of it, `onSleep`: stopped but kept to resume) without
* taking it out first. The grid shows
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
@@ -40,11 +42,13 @@ import {
GripVertical,
MessageSquare,
Minimize2,
Moon,
Rows2,
SquareTerminal,
X,
} from "lucide-react";
import type { RunHandle } from "../../lib/api";
import { canSleep } from "../../lib/stopSession";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import {
EMPTY_GROUPS,
@@ -402,6 +406,7 @@ export function PaneGrid({
onNewSplit,
stateOf,
onStop,
onSleep,
keyOf = (slot) => slot,
kept = [],
}: {
@@ -410,8 +415,10 @@ export function PaneGrid({
keyOf?: (slot: number) => number;
/** Panes kept mounted, always hidden — never in the layout. */
kept?: { key: number; node: ReactNode }[];
/** Stops the session in `slot` (the header's X). */
/** Stops (and archives) the session in `slot` (the header's X). */
onStop?: (slot: number) => void;
/** Sleeps the AI session in `slot`: stopped, kept to resume (the moon). */
onSleep?: (slot: number) => void;
/** A live AI run's state for its header dot (none for a shell). */
stateOf?: (r: RunHandle) => RunState | null;
/** Every slot to keep mounted; those not in the layout are hidden. */
@@ -641,6 +648,18 @@ export function PaneGrid({
>
<Minimize2 className="h-3.5 w-3.5" />
</button>
{run && onSleep && canSleep(run) && (
<button
type="button"
data-testid={`pane-sleep-${slot}`}
title={t("panes.sleep")}
aria-label={t("panes.sleep")}
onClick={() => onSleep(slot)}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
<Moon className="h-3.5 w-3.5" />
</button>
)}
{run && onStop && (
<button
type="button"
@@ -696,6 +715,11 @@ export function PaneGrid({
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
{t("panes.closeShort")}
</MenuItem>
{onSleep && canSleep(runOf(paneCtx.menu.item)) && (
<MenuItem testId="pane-menu-sleep" onSelect={pickPane(onSleep)}>
{t("panes.sleep")}
</MenuItem>
)}
{onStop && runOf(paneCtx.menu.item) && (
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
{t("panes.stop")}
+14 -1
View File
@@ -355,9 +355,20 @@ export const TerminalView = memo(function TerminalView({
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
// because that protocol reports button presses only, never the wheel — so
// xterm takes the emulation path for it as well.
term.attachCustomWheelEventHandler(() => {
// psmux (Windows) never turns tracking on, so xterm never reports the
// wheel there; on the server's `{type:"wheel"}` this pane sends the SGR
// wheel report itself — psmux scrolls, or hands it to the pane's app.
// ponytail: at 1;1, which is all psmux reports to the app anyway.
let ownWheel = false;
term.attachCustomWheelEventHandler((ev) => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
if (!tracked && ownWheel) {
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
}
return false;
}
return tracked || term.buffer.active.type !== "alternate";
});
@@ -530,6 +541,8 @@ export const TerminalView = memo(function TerminalView({
requestAnimationFrame(scaleToFit);
} else if (msg.type === "lead") {
takeLead();
} else if (msg.type === "wheel") {
ownWheel = true;
} else if (msg.type === "exit") {
ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
@@ -231,6 +231,24 @@ describe("TerminalView", () => {
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
});
it("sends the wheel itself once the server says its tmux never tracks the mouse", () => {
// psmux (Windows) never turns mouse tracking on for its client, so xterm
// never reports the wheel and the pane could not be scrolled at all.
termState.modes.mouseTrackingMode = "none";
termState.buffer.active.type = "alternate";
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
ws.onmessage?.({ data: '{"type":"wheel"}' });
const wheel = wheelHandlerMock.mock.calls[wheelHandlerMock.mock.calls.length - 1]![0]!;
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
// Tracked again (a tmux that does report): xterm sends it, never twice.
termState.modes.mouseTrackingMode = "any";
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
expect(ws.sent).toHaveLength(2);
});
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
@@ -2,31 +2,186 @@
* @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
* key a clone (Add workspace → Clone) and the pushes from that clone use —
* make one, copy its public half to add to GitHub/GitLab, replace or delete
* it — and, for a user with their own Linux account (S3), their own HTTPS
* 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
* 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
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
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 =
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
const BTN =
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
// The regions AWS CodeCommit runs in.
const AWS_REGIONS = [
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"ca-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"sa-east-1",
"me-south-1",
"af-south-1",
"il-central-1",
];
function Step({ n, children }: { n: number; children: ReactNode }) {
return (
<div className="flex gap-2 text-[11px] text-fg-secondary">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-3 text-[10px] text-fg-primary">
{n}
</span>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
function Code({ children }: { children: ReactNode }) {
return (
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
{children}
</code>
);
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
/**
* One kind of git host and the key it takes: no key → a Generate button;
* a key → step 1 (copy its public half) followed by the host's own steps.
*/
function KeyCard({
title,
sub,
type,
k,
id,
busy,
onGenerate,
onRemove,
children,
}: {
title: string;
sub: string;
type: "ed25519" | "rsa";
k: Key | undefined;
id: GitIdentity;
busy: boolean;
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
children: ReactNode;
}) {
const { t } = useTranslation("settings");
return (
<section aria-label={title} 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">{title}</div>
<div className="text-[10px] text-fg-muted">{sub}</div>
</div>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => void onGenerate(type)}
>
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
{k && id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void onRemove(type)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
{k ? (
<>
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.stepCopy")}</span>
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
value={k.publicKey}
rows={type === "rsa" ? 3 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
/>
<div className="truncate font-mono text-[10px] text-fg-muted">{k.fingerprint}</div>
</Step>
{children}
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
)}
</section>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
@@ -37,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 {
@@ -65,23 +222,28 @@ export function GitIdentitySettings() {
};
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
const generate = async () => {
if (
id?.ssh &&
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(
() => api.gitIdentity.generateKey(Boolean(id?.ssh), keyType),
// An account may hold one of each; an older server answers `ssh` only.
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
// 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 removeKey = async () => {
const removeNamed = async (keyId: string) => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
@@ -89,7 +251,30 @@ export function GitIdentitySettings() {
}))
)
return;
await run(() => api.gitIdentity.removeKey());
await run(() => api.gitIdentity.removeKeyById(keyId));
};
const generate = async (type: "ed25519" | "rsa") => {
const taken = !!keyOf(type);
if (
taken &&
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(() => api.gitIdentity.generateKey(taken, type), t("gitIdentity.keyMade"));
};
const removeKey = async (type: "ed25519" | "rsa") => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKey(type));
};
const saveLogin = async () => {
@@ -109,6 +294,76 @@ export function GitIdentitySettings() {
if (ok) setForm(null);
};
// The SSH login name per host (`url.<…>.insteadOf`), so a plain
// `ssh://host/…` URL logs in. AWS CodeCommit's (user = the IAM SSH key ID,
// one host per region) gets its own form under the RSA key; anything else
// is the "other hosts" form.
const sshUsers = id?.sshUsers ?? [];
const awsRows = sshUsers.flatMap((r) => {
const m = CODECOMMIT_RE.exec(r.host);
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", alias: "", key: "" });
const saveAws = async () => {
let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
}
const alias = aws.alias.trim().toLowerCase();
const ok = await run(
() =>
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: "", alias: "" });
};
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(),
...(alias ? { alias } : {}),
...(other.key ? { key: other.key } : {}),
}),
t("gitIdentity.otherSaved", { host: alias || host })
);
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 (
!(await confirmDialog({
@@ -130,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(
() =>
@@ -151,7 +431,7 @@ export function GitIdentitySettings() {
return (
<div className="space-y-4">
<p className="max-w-2xl text-xs text-fg-muted">
<p className="text-xs text-fg-muted">
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
</p>
@@ -162,7 +442,7 @@ export function GitIdentitySettings() {
)}
<form
className="max-w-2xl space-y-2 rounded-lg border border-border p-3"
className="space-y-2 rounded-lg border border-border p-3"
onSubmit={(e) => {
e.preventDefault();
void saveAuthor();
@@ -202,75 +482,347 @@ export function GitIdentitySettings() {
)}
</form>
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.sshTitle")}
</h3>
{id.writable && (
<select
aria-label={t("gitIdentity.keyType")}
value={keyType}
disabled={busy}
onChange={(e) => setKeyType(e.target.value === "rsa" ? "rsa" : "ed25519")}
className="select !py-1 !text-xs"
>
<option value="ed25519">{t("gitIdentity.keyEd25519")}</option>
<option value="rsa">{t("gitIdentity.keyRsa")}</option>
</select>
)}
{id.writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
</div>
{id.ssh ? (
<>
<textarea
readOnly
aria-label={t("gitIdentity.publicKey")}
value={id.ssh.publicKey}
rows={3}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
/>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{id.ssh.type === "rsa" ? "RSA 4096 · " : id.ssh.type ? "ED25519 · " : ""}
{id.ssh.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(id.ssh?.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}
onClick={() => void removeKey()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
<PageTabs
ariaLabel={t("gitIdentity.sshTitle")}
tabs={sshTabs}
activeId={activeSshTab}
onSelect={setSshTab}
>
{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:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
)}
{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/&lt;repo&gt;
</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.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.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="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_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`}
/>
<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()}
>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
</div>
</section>
)}
</PageTabs>
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
<div className="text-[10px] text-fg-muted">
{terminal === "foreign"
? t("gitIdentity.terminalForeign")
: t("gitIdentity.terminalHint")}
</div>
</div>
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
<Switch
label={t("gitIdentity.terminal")}
checked={terminal === "on"}
disabled={busy || !id.writable || terminal === "foreign"}
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
/>
</div>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
</div>
{member && (
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
<div className="space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.httpsTitle")}
@@ -2,11 +2,16 @@
* @file Tests for Settings → Git → Your git identity: a user with no key
* makes one, a key shows only its public half and copies it, replacing asks
* 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.
* 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" — 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";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitIdentitySettings } from "../GitIdentitySettings";
import { api, type GitIdentity } from "../../../lib/api";
@@ -19,6 +24,10 @@ 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(),
saveHttps: vi.fn(),
saveAuthor: vi.fn(),
removeHttps: vi.fn(),
@@ -39,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();
});
@@ -48,10 +61,11 @@ describe("GitIdentitySettings", () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
const gh = await screen.findByRole("region", { name: /GitHub/ });
await userEvent.click(within(gh).getByRole("button", { name: "Generate key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
});
it("copies the public key and asks before replacing it", async () => {
@@ -85,16 +99,86 @@ describe("GitIdentitySettings", () => {
expect(await screen.findByText("github.com")).toBeInTheDocument();
});
it("makes an RSA key when that type is picked (AWS CodeCommit takes only RSA)", async () => {
it("makes the RSA key from the AWS CodeCommit section", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
);
render(<GitIdentitySettings />);
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
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");
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
expect(await screen.findByLabelText("Public key RSA")).toBeInTheDocument();
});
it("keeps each key in its own section and deletes each on its own", async () => {
const RSA = {
type: "rsa" as const,
publicKey: "ssh-rsa AAAAB3 an@agenthub",
fingerprint: "SHA256:r",
};
const ED = { ...KEY, type: "ed25519" as const };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" })
);
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
render(<GitIdentitySettings />);
const gh = await screen.findByRole("region", { name: /GitHub/ });
expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
// 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" }));
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
});
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
const H = "git-codecommit.eu-west-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
);
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 ");
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 · 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();
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H);
});
it("saves a user for any other SSH host under the advanced section", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY, sshUsers: [] }));
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({ ssh: KEY, sshUsers: [{ host: "git.example.com", user: "deploy" }] })
);
render(<GitIdentitySettings />);
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")!;
await userEvent.click(within(form).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: "git.example.com",
user: "deploy",
});
expect(await screen.findByText("deploy@git.example.com · Any key")).toBeInTheDocument();
});
it("warns while the commit author is unset and saves it", async () => {
@@ -118,8 +202,73 @@ describe("GitIdentitySettings", () => {
view({ account: "host", writable: false, ssh: KEY })
);
render(<GitIdentitySettings />);
expect(await screen.findByLabelText("Public key")).toBeInTheDocument();
expect(await screen.findByLabelText(/^Public key/)).toBeInTheDocument();
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"));
});
});
+1
View File
@@ -115,6 +115,7 @@ export const i18nReady = i18n
"splash",
"lanes",
"users",
"admin",
"onboarding",
"artifacts",
],
+37
View File
@@ -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}}",
+24 -6
View File
@@ -430,7 +430,7 @@
"tabs.new": "New tab",
"tabs.newTerminal": "New terminal",
"tabs.terminal": "Terminal",
"tabs.close": "Close tab (stops its session)",
"tabs.close": "Close tab (stops and archives its session)",
"tabs.full": "All 10 sessions are in use — stop one first",
"tabs.agentSettings": "Agent settings…",
"tabs.settings": "Settings",
@@ -465,7 +465,8 @@
"panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down with a new terminal",
"panes.closeShort": "Move to own tab",
"panes.stop": "Stop session",
"panes.stop": "Stop and archive session",
"panes.sleep": "Sleep session (stop, keep to resume later)",
"changes.copyPath": "Copy relative path",
"changes.viewCommit": "View commit",
"changes.copySha": "Copy commit SHA",
@@ -479,8 +480,13 @@
"tabs.renameHint": "Double-click to rename",
"tabs.stopTitle": "Stop “{{name}}”?",
"tabs.stopGroupTitle": "Stop the {{count}} sessions in this tab?",
"tabs.stopBody": "Closing the tab stops its session, even mid-turn. It can be resumed later from Recents.",
"tabs.stopBody": "Stopping ends the session, even mid-turn, and archives it: it leaves this workspace's list (Unarchive brings it back). To keep it for later, use Sleep instead.",
"tabs.stopConfirm": "Stop session",
"tabs.sleep": "Sleep (stop, keep to resume later)",
"tabs.sleepTitle": "Sleep “{{name}}”?",
"tabs.sleepGroupTitle": "Sleep the {{count}} sessions in this tab?",
"tabs.sleepBody": "Sleep stops the session, even mid-turn, but keeps it in this workspace's list. Open it and send a message to resume it.",
"tabs.sleepConfirm": "Sleep",
"changes.loadMore": "Load more commits",
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
"changes.splitter": "Resize the changes panel",
@@ -571,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",
@@ -585,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",
@@ -614,8 +631,9 @@
"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."
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
}
+1
View File
@@ -87,5 +87,6 @@
"sidebar": "Sidebar",
"now": "now",
"users": "Users",
"admin": "Administration",
"files": "Files"
}
+43 -6
View File
@@ -901,8 +901,8 @@
"title": "Your git identity",
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
"descriptionHost": "The SSH key of the account this AgentHub runs as. Add workspace → Clone uses it, and the clone keeps it for its later fetch, pull and push. HTTPS logins for this account are the Git credentials above.",
"sshTitle": "SSH key",
"generate": "Generate SSH key",
"sshTitle": "SSH keys",
"generate": "Generate key",
"replace": "Replace key",
"replaceConfirm": "Replace the SSH key? Clones that use the old key stop working until the new public key is added to their host.",
"keyMade": "SSH key ready — add its public key to your git host.",
@@ -910,14 +910,51 @@
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — RSA only). Paste the whole line as one line. The private key never leaves the server.",
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
"noKey": "No key yet. Generate one to start.",
"httpsTitle": "HTTPS logins",
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.",
"terminal": "Use this key for git in the terminal",
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubSub": "ED25519 key — the git@… SSH address",
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
"awsTitle": "AWS CodeCommit",
"awsSub": "RSA 4096 key — AWS accepts only RSA",
"stepCopy": "Copy the public key.",
"awsStepUpload": "In the AWS console: IAM → Users → your user → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, and paste it.",
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here and pick the region of your repositories:",
"awsKeyId": "SSH key ID",
"awsRegion": "Region",
"awsSave": "Save",
"awsSaved": "SSH key ID saved for {{region}}",
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
"otherTitle": "Other SSH hosts (advanced)",
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
"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.",
"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",
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
"keyRsa": "RSA 4096 (AWS CodeCommit, older servers)"
"tabGit": "GitHub · GitLab",
"tabMore": "More keys",
"tabOther": "Other hosts"
},
"gitCreds": {
"title": "Git credentials",
+5 -1
View File
@@ -31,5 +31,9 @@
"installedTitle": "Version {{version}} is installed but not running",
"later": "Not now",
"skipVersion": "Skip this version",
"skipVersionHint": "Don't ask again about this version; a newer one will still be offered."
"skipVersionHint": "Don't ask again about this version; a newer one will still be offered.",
"autoFix": "Auto fix",
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
"autoFixWorkspace": "AgentHub update",
"autoFixError": "Could not start the Auto fix session"
}
+35
View File
@@ -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"
}
}
+37
View File
@@ -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}}",
+24 -6
View File
@@ -430,7 +430,7 @@
"tabs.new": "Tab mới",
"tabs.newTerminal": "Terminal mới",
"tabs.terminal": "Terminal",
"tabs.close": "Đóng tab (dừng phiên của nó)",
"tabs.close": "Đóng tab (dừng và lưu trữ phiên của nó)",
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
"tabs.agentSettings": "Cài đặt agent…",
"tabs.settings": "Cài đặt",
@@ -465,7 +465,8 @@
"panes.splitNew.right": "Chia sang phải với terminal mới",
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
"panes.closeShort": "Tách ra tab riêng",
"panes.stop": "Dừng phiên",
"panes.stop": "Dừng và lưu trữ phiên",
"panes.sleep": "Cho phiên ngủ (dừng, giữ lại để resume sau)",
"changes.copyPath": "Sao chép đường dẫn tương đối",
"changes.viewCommit": "Xem commit",
"changes.copySha": "Sao chép SHA của commit",
@@ -479,8 +480,13 @@
"tabs.renameHint": "Nhấp đúp để đổi tên",
"tabs.stopTitle": "Dừng “{{name}}”?",
"tabs.stopGroupTitle": "Dừng {{count}} session trong tab này?",
"tabs.stopBody": "Đóng tab sẽ dừng phiên của nó, kể cả khi đang chạy dở. Bạn có thể tiếp tục (resume) sau từ mục Gần đây.",
"tabs.stopBody": "Dừng sẽ kết thúc phiên, kể cả khi đang chạy dở, và lưu trữ nó: phiên rời khỏi danh sách của workspace (bấm Bỏ lưu trữ để đưa lại). Muốn giữ lại để dùng tiếp thì hãy chọn Ngủ.",
"tabs.stopConfirm": "Dừng phiên",
"tabs.sleep": "Ngủ (dừng, giữ lại để resume sau)",
"tabs.sleepTitle": "Cho “{{name}}” ngủ?",
"tabs.sleepGroupTitle": "Cho {{count}} session trong tab này ngủ?",
"tabs.sleepBody": "Ngủ sẽ dừng phiên, kể cả khi đang chạy dở, nhưng vẫn giữ nó trong danh sách của workspace. Mở phiên và gửi tin nhắn để resume.",
"tabs.sleepConfirm": "Ngủ",
"changes.loadMore": "Tải thêm commit",
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
"changes.splitter": "Đổi độ rộng khung thay đổi",
@@ -571,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",
@@ -585,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",
@@ -614,8 +631,9 @@
"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."
"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.",
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
}
+1
View File
@@ -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"
}
+42 -5
View File
@@ -902,7 +902,7 @@
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
"descriptionHost": "SSH key của tài khoản mà AgentHub đang chạy. Add workspace → Clone dùng key này, và bản clone giữ lại cho các lần fetch, pull, push sau. Đăng nhập HTTPS của tài khoản này là mục Git credentials ở trên.",
"sshTitle": "SSH key",
"generate": "Tạo SSH key",
"generate": "Tạo key",
"replace": "Thay key",
"replaceConfirm": "Thay SSH key? Các bản clone dùng key cũ sẽ không hoạt động cho tới khi public key mới được thêm vào host.",
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
@@ -910,14 +910,51 @@
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — chỉ nhận RSA). Dán nguyên dòng, trên một dòng. Private key không bao giờ rời khỏi server.",
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
"noKey": "Chưa có key. Bấm Tạo key để bắt đầu.",
"httpsTitle": "Đăng nhập HTTPS",
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.",
"terminal": "Dùng key này cho git trong terminal",
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
"awsTitle": "AWS CodeCommit",
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
"stepCopy": "Copy public key.",
"awsStepUpload": "Trên AWS console: IAM → Users → user của bạn → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, rồi dán vào.",
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây và chọn region chứa repository:",
"awsKeyId": "SSH key ID",
"awsRegion": "Region",
"awsSave": "Lưu",
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
"otherTitle": "Host SSH khác (nâng cao)",
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
"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.",
"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",
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
"keyRsa": "RSA 4096 (AWS CodeCommit, server cũ)"
"tabGit": "GitHub · GitLab",
"tabMore": "Key khác",
"tabOther": "Host khác"
},
"gitCreds": {
"title": "Git credentials",
+5 -1
View File
@@ -31,5 +31,9 @@
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
"later": "Để sau",
"skipVersion": "Bỏ qua phiên bản này",
"skipVersionHint": "Không hỏi lại về phiên bản này; phiên bản mới hơn vẫn sẽ được thông báo."
"skipVersionHint": "Không hỏi lại về phiên bản này; phiên bản mới hơn vẫn sẽ được thông báo.",
"autoFix": "Tự sửa",
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
"autoFixWorkspace": "Cập nhật AgentHub",
"autoFixError": "Không mở được phiên Tự sửa"
}
+35
View File
@@ -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"
}
}
+165 -13
View File
@@ -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,12 +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 }),
}),
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
/** 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 — 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) }),
/** 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) =>
@@ -2936,8 +3053,12 @@ export const api = {
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its
* version, and this platform's upgrade advice when it is too old. */
tmuxAvailable: () =>
request<{ available: boolean; version?: string | null; upgrade?: string | null }>(
"/run/tmux"
),
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
@@ -2988,9 +3109,13 @@ export const api = {
method: "POST",
body: JSON.stringify({ mode }),
}),
/** DELETE /api/run/:id - kill the tmux session. */
kill: (id: string) =>
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
/** DELETE /api/run/:id - kill the tmux session. `archive` (the UI's Stop)
* also archives the session it held; Sleep leaves it listed to resume. */
kill: (id: string, opts?: { archive?: boolean }) =>
request<{ ok: true; archived?: string | null }>(
`/run/${encodeURIComponent(id)}${opts?.archive ? "?archive=1" : ""}`,
{ method: "DELETE" }
),
/** GET /api/run/interrupted - sessions whose pane died under them (reboot,
* killed tmux, crash), newest first; the caller's own workspaces only. */
interrupted: () => request<{ items: InterruptedRun[] }>("/run/interrupted"),
@@ -4186,9 +4311,36 @@ export interface GitIdentity {
account: "member" | "host";
/** False for the host's identity seen by a non-admin. */
writable: boolean;
ssh: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string } | null;
ssh: {
type?: "ed25519" | "rsa";
publicKey: string;
fingerprint: string;
/** 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: 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 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;
+57
View File
@@ -0,0 +1,57 @@
/**
* @file stopSession — the one way the UI ends live panes: **Stop** kills the
* pane and ARCHIVES the session it held (`DELETE /api/run/:id?archive=1`), so
* a session the user closed leaves its workspace's lists; **Sleep** kills the
* pane and KEEPS the session listed, to be resumed later by sending it a
* message. Both ask first when any pane holds an AI agent (it may be
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
* session's right-click menu (`SessionMenu`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { TFunction } from "i18next";
import { api, type RunHandle } from "./api";
import { confirmDialog } from "./confirm";
import { toast } from "./toast";
/** Whether a pane can sleep: an AI pane (a shell has no session to keep). */
export const canSleep = (run: RunHandle | null | undefined): boolean =>
!!run && run.tool !== "shell";
/**
* Stop (archive) or sleep (keep) `runs` after a confirm, and return the ids
* that were ended. `name` titles a one-pane confirm; `t` is the `lanes`
* namespace.
*/
export async function endRuns(
runs: RunHandle[],
{ sleep = false, name, t }: { sleep?: boolean; name: string; t: TFunction }
): Promise<string[]> {
const list = sleep ? runs.filter(canSleep) : runs;
if (!list.length) return [];
const kind = sleep ? "sleep" : "stop";
if (
list.some((r) => r.tool !== "shell") &&
!(await confirmDialog({
title:
list.length > 1
? t(`tabs.${kind}GroupTitle`, { count: list.length })
: t(`tabs.${kind}Title`, { name }),
message: t(`tabs.${kind}Body`),
confirmLabel: t(`tabs.${kind}Confirm`),
destructive: !sleep,
}))
)
return [];
const ended: string[] = [];
for (const r of list) {
try {
await api.run.kill(r.id, { archive: !sleep });
ended.push(r.id);
} catch (err) {
toast.error(err);
}
}
return ended;
}
+66
View File
@@ -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>
);
}
+97 -9
View File
@@ -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.
+64 -9
View File
@@ -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">
+26 -20
View File
@@ -81,8 +81,9 @@ import type {
} from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { endRuns } from "../lib/stopSession";
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
@@ -423,27 +424,12 @@ function ConsoleArea({
else onNewSession?.();
};
// A pane header's X: stops that one session, as a one-pane tab's X does —
// an AI session may be mid-turn, so it asks first; a shell holds no agent.
const stopPane = async (slot: number) => {
// A pane header's X stops (and archives) that one session, its moon sleeps
// it — as a one-pane tab's buttons do (`endRuns` asks first for an AI pane).
const stopPane = async (slot: number, sleep = false) => {
const run = runOf(slot);
if (!run) return;
if (
run.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: label(run) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(run.id);
ended(slot);
} catch (err) {
toast.error(err);
}
if ((await endRuns([run], { sleep, name: label(run), t })).length) ended(slot);
};
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
@@ -632,6 +618,7 @@ function ConsoleArea({
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
onSleep={(slot) => void stopPane(slot, true)}
// Keyed by workspace too, so a kept pane coming back is the same one.
keyOf={allTabs ? undefined : (slot) => paneKey(selectedLaneId ?? 0, slot)}
kept={kept}
@@ -665,6 +652,25 @@ export function Workspace() {
const { local } = usePermissions();
const [searchParams, setSearchParams] = useSearchParams();
// The server's tmux is older than this platform needs (psmux < 3.4.0 on
// Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is.
// Sticky (an error toast) so the command can be copied. An older server or
// no answer says nothing.
useEffect(() => {
let live = true;
(async () => {
try {
const { upgrade } = await api.run.tmuxAvailable();
if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
} catch {
/* nothing to say */
}
})();
return () => {
live = false;
};
}, [tLanes]);
// Lane state: the list is the shared store the sidebar reads too; the
// selection is the URL's `?lane=` (the sidebar sets it, so does a
// notification click), falling back to the last one shown, then the first.
@@ -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();
});
});
+52 -3
View File
@@ -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();
});
});
+30 -3
View File
@@ -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();
});
});
+35 -6
View File
@@ -459,6 +459,23 @@ describe("Workspace page — lane integration", () => {
});
describe("Workspace layout", () => {
it("asks the user to upgrade a tmux older than their platform needs", async () => {
const { toast } = await import("../../lib/toast");
const shown = vi.spyOn(toast, "error");
const run = api.run as unknown as Record<string, unknown>;
run.tmuxAvailable = vi.fn().mockResolvedValue({
available: true,
version: "3.3.2",
upgrade: "psmux 3.3.2 is older than 3.4.0 — winget upgrade marlocarlo.psmux",
});
onTestFinished(() => {
delete run.tmuxAvailable;
shown.mockRestore();
});
await renderWorkspace();
expect(shown).toHaveBeenCalledWith(expect.stringContaining("winget upgrade marlocarlo.psmux"));
});
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
lanesToReturn = [];
await renderWorkspace();
@@ -749,11 +766,21 @@ describe("console tabs", () => {
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument();
});
it("a tab's moon sleeps its session: stopped but not archived", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-sleep-0"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: false });
await waitFor(() =>
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
);
});
it("closing the active tab stops its session and opens the next one", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-close-0"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: true });
await waitFor(() =>
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
);
@@ -899,8 +926,8 @@ describe("split panes", () => {
withRuns([live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2"));
await settle();
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: true });
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
expect(shownPanes()).toEqual(["pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
@@ -915,7 +942,7 @@ describe("split panes", () => {
await userEvent.setup().click(screen.getByTestId("pane-stop-1"));
await settle();
expect(api.run.kill).toHaveBeenCalledTimes(1);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1", { archive: true });
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
expect(screen.queryByTestId("console-tab-1")).toBeNull();
});
@@ -1000,9 +1027,11 @@ describe("split panes", () => {
await right(screen.getByTestId("console-tab-2"));
await user.click(
within(screen.getByTestId("console-tab-menu-2")).getByText("Close tab (stops its session)")
within(screen.getByTestId("console-tab-menu-2")).getByText(
"Close tab (stops and archives its session)"
)
);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
});
it("a split menu starts a new terminal in the first free slot", async () => {
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.0 (rebuild client to match)
UI build v2.8.7 (rebuild client to match)
</span>
</p>
</div>
+38 -10
View File
@@ -85,7 +85,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/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,12 +1067,15 @@ cannot collide on `index.lock`.
#### Your git identity
```
GET /api/me/git { account: "member"|"host", writable, ssh: { publicKey, fingerprint } | null, https: [{ protocol, host, username, hasToken }] }
POST /api/me/git/ssh-key Make the SSH key — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS without replace) → 201, same shape
DELETE /api/me/git/ssh-key Delete it (404 ENOTFOUND)
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
@@ -2792,7 +2820,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
```http
GET /api/run List all live runs (computed fresh from tmux state)
GET /api/run/tmux { available: bool } — whether tmux is on PATH
GET /api/run/tmux { available: bool, version, upgrade } — whether tmux is on PATH, its version, and this platform's upgrade advice when it is older than AgentHub asks for (psmux 3.4.0 on Windows → `winget upgrade marlocarlo.psmux`; tmux 3.7 on macOS → `brew upgrade tmux`, on Linux → build the release), else null; the Workspace shows it as a sticky toast
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
@@ -2807,7 +2835,7 @@ POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
GET /api/run/:id Run handle (returns live run state)
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
DELETE /api/run/:id[?archive=1] Kill the pane → {ok}. `archive=1` (the UI's Stop) also archives the session it held → {ok, archived: <session id> | null}; without it (Sleep, the CLI) the session stays listed to resume
```
`:id` must be a workspace pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
@@ -3209,7 +3237,7 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed
+2 -2
View File
@@ -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 |
@@ -288,7 +288,7 @@ Because a workspace's services are fully detached, restarting the dashboard (or
| Command | Description |
| ------- | ----------- |
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections, whether `tmux` is on **the server's** PATH (asked via `GET /api/run/tmux` — a systemd `--user` service does not read your shell profile), remote-source status |
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections, whether `tmux` is on **the server's** PATH (asked via `GET /api/run/tmux` — a systemd `--user` service does not read your shell profile) and, when it is older than the platform needs, how to upgrade it, remote-source status |
| `agenthub info` | The raw `/api/settings/info` JSON (pipe it to `jq`) |
| `agenthub export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `agenthub import-data` |
| `agenthub cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
+3 -2
View File
File diff suppressed because one or more lines are too long
+77 -19
View File
@@ -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), an SSH key AgentHub makes for them 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 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;
@@ -675,6 +699,29 @@ a pane really is. Each case below is refused or bounded, with a test in
click makes on a member's pane (start, type, stop) still waits, up to the
3 s. Every other synchronous call
that runs as a member (git in their folder, `write-as`) stops after 5 s.
- **The host's own tmux server is bounded too.** Every call on it stops
after 5 s, and one that timed out is not asked again for a minute
(`host: tmux server not answering` meanwhile), so a spinning server can no
longer freeze the dashboard (it once did for five minutes). Every call on a
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 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
@@ -736,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
@@ -779,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)).
@@ -811,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
@@ -836,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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.0",
"version": "2.8.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.0",
"version": "2.8.7",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.0",
"version": "2.8.7",
"bin": {
"agenthub": "bin/agenthub.js"
},
+11 -22
View File
@@ -247,15 +247,12 @@ function systemdStatus() {
* service can still see a different one — `GET /api/run/tmux` is the server's
* own answer, and `agenthub doctor` reports that.
*
* Also WARN below tmux 3.7, the first with pane scrollbars (`tmux.js` turns
* them on), and when a new binary still talks to an older running server — a
* server keeps its version until every tmux session has closed. The doctor
* only says so: it never installs or restarts tmux.
* Also WARN below the platform's minimum (`tmux.upgradeAdvice`: psmux 3.4.0
* on Windows, tmux 3.7 elsewhere — the same advice the Workspace shows), and
* when a new binary still talks to an older running server — a server keeps
* its version until every tmux session has closed. The doctor only says so:
* it never installs or restarts tmux.
*/
const hasScrollbars = (version) => {
const m = /(\d+)\.(\d+)/.exec(version || "");
return !!m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 7));
};
function tmuxStatus() {
const tmux = require("../server/lib/tmux");
@@ -268,26 +265,18 @@ function tmuxStatus() {
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
];
}
if (!hasScrollbars(client)) {
return [
"warn",
"tmux",
`${client} works, but terminal panes get a scrollbar only from tmux 3.7. ` +
"Upgrade: brew upgrade tmux (macOS); on Linux, when the distro ships an older one " +
"(Ubuntu 24.04 has 3.4), build the release from https://github.com/tmux/tmux/releases " +
"(needs libevent-dev libncurses-dev bison: ./configure && make && sudo make install)",
];
}
const advice = tmux.upgradeAdvice(client);
if (advice) return ["warn", "tmux", `${client} works, but upgrade it: ${advice}`];
const server = tmux.serverVersion();
if (server && !hasScrollbars(server)) {
if (server && tmux.upgradeAdvice(server)) {
return [
"warn",
"tmux",
`${client} installed, but the running tmux server is ${server} — panes get their ` +
"scrollbar once every tmux session has closed (or after a reboot)",
`${client} installed, but the running tmux server is ${server} — it is used ` +
"until every tmux session has closed (or after a reboot)",
];
}
return ["ok", "tmux", `${client} on PATH — the run console can start sessions, with scrollbars`];
return ["ok", "tmux", `${client} on PATH — the run console can start sessions`];
}
function countLegacyHookEntries() {
+3 -1
View File
@@ -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=""
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# @file tmux-member.sh
# @description Server mode, one Linux user per account (S3): installed as
# /opt/agenthub/libexec/tmux-member, the one tmux the host may run AS a
# member (sudoers). Called as `tmux-member -S <socket> <tmux args…>`, it runs
# the args with the binary of the tmux server already listening on that
# socket (`/proc/<pid>/exe`, which still runs after an upgrade replaced the
# file), else with TMUX_BIN from /etc/agenthub/server.conf (no server yet:
# this call starts one, on the installed version). A client must match its
# server: a tmux 3.8 client asking a 3.6 server kept it at 100% CPU.
#
# It runs as the member and execs only a process THEY own (under hidepid they
# see no other), so it can run nothing they could not run themselves.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -u
bin=$(sed -n 's/^TMUX_BIN=//p' /etc/agenthub/server.conf 2>/dev/null | head -n 1)
[[ "$bin" == /* ]] || { echo "tmux-member: no TMUX_BIN in /etc/agenthub/server.conf" >&2; exit 2; }
if [ "${1:-}" = -S ] && [ -n "${2:-}" ]; then
# /proc/net/unix: Num RefCount Protocol Flags Type St Inode Path; 00010000 = listening.
inode=$(awk -v s="$2" '$4 == "00010000" && $8 == s { print $7; exit }' /proc/net/unix 2>/dev/null)
if [ -n "$inode" ]; then
for d in /proc/[0-9]*; do
[ -O "$d" ] || continue
read -r comm <"$d/comm" 2>/dev/null || continue
[[ "$comm" == tmux* ]] || continue
for fd in "$d"/fd/*; do
if [ "$(readlink "$fd" 2>/dev/null)" = "socket:[$inode]" ] && [ -x "$d/exe" ]; then
exec "$d/exe" "$@"
fi
done
done
fi
fi
exec "$bin" "$@"
+1 -1
View File
@@ -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>
*/
+2 -2
View File
@@ -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";
}
+17
View File
@@ -68,6 +68,23 @@ const env = {
};
delete env.TMUX;
delete env.TMUX_PANE;
// psmux (the WinGet `tmux` on Windows) ignores TMUX_TMPDIR — and 3.3.x its
// PSMUX_DATA_DIR too — so every tmux call reached the user's REAL server:
// quit.test.js's Quit killed every live lane pane, and the kill-server below
// the rest. Only `-L` isolates there, which no env var can add to each call,
// so on Windows the suites get no tmux at all; theirs skip (or fail) instead.
if (process.platform === "win32") {
const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH");
if (key) {
env[key] = env[key]
.split(path.delimiter)
.filter(
(d) => !["tmux.exe", "tmux.cmd", "tmux.bat"].some((f) => fs.existsSync(path.join(d, f)))
)
.join(path.delimiter);
}
delete env.PSMUX_SESSION;
}
// Git exports these to a hook it runs from a linked worktree (the pre-commit
// hook runs this suite): inherited, every `git init`/`commit` a test makes in
// its temp folder went to the REAL repository instead — commits on the
+168 -47
View File
@@ -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`);
});
});
+274 -8
View File
@@ -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");
@@ -267,16 +271,34 @@ describe("/api/me/git", () => {
assert.equal(del.body.ssh, null);
});
it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => {
await request("POST", "/api/me/git/ssh-key", {});
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" });
it("keeps an RSA 4096 key (AWS CodeCommit) BESIDE the ed25519 one", async () => {
const ed = await request("POST", "/api/me/git/ssh-key", {});
const rsa = await request("POST", "/api/me/git/ssh-key", { type: "rsa" });
assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
assert.equal(rsa.body.ssh.type, "rsa");
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
assert.deepEqual(
rsa.body.sshKeys.map((k) => k.type),
["ed25519", "rsa"]
);
// `ssh` stays the first key, as for a client that knows only one.
assert.equal(rsa.body.ssh.publicKey, ed.body.ssh.publicKey);
assert.match(rsa.body.sshKeys[1].publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600);
assert.ok(!fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was replaced");
assert.ok(fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was kept");
// Each type is replaced and deleted on its own.
assert.equal((await request("POST", "/api/me/git/ssh-key", { type: "rsa" })).status, 409);
const again = await request("POST", "/api/me/git/ssh-key", { type: "rsa", replace: true });
assert.notEqual(again.body.sshKeys[1].publicKey, rsa.body.sshKeys[1].publicKey);
assert.equal(again.body.sshKeys[0].publicKey, ed.body.ssh.publicKey);
const noEd = await request("DELETE", "/api/me/git/ssh-key?type=ed25519");
assert.deepEqual(
noEd.body.sshKeys.map((k) => k.type),
["rsa"]
);
assert.equal(noEd.body.ssh.type, "rsa");
assert.equal((await request("DELETE", "/api/me/git/ssh-key?type=ed25519")).status, 404);
assert.equal((await request("DELETE", "/api/me/git/ssh-key?type=dsa")).status, 400);
const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
encoding: "utf8",
});
@@ -293,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");
@@ -300,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 () => {
@@ -317,6 +344,236 @@ describe("/api/me/git", () => {
});
});
describe("/api/me/git/ssh-terminal (issue #82)", () => {
const gitGet = () => {
try {
return execFileSync("git", ["config", "--global", "--get", "core.sshCommand"], {
encoding: "utf8",
}).trim();
} catch {
return "";
}
};
it("points the account's terminal git at the key, keeping the ~/.ssh defaults", async () => {
const made = await request("POST", "/api/me/git/ssh-key", { replace: true });
assert.equal(made.status, 201);
// The host's terminal is an admin's switch: never turned on by itself.
assert.equal(made.body.ssh.terminal, "off");
const on = await request("PUT", "/api/me/git/ssh-terminal", { on: true });
assert.equal(on.status, 200);
assert.equal(on.body.ssh.terminal, "on");
const cmd = gitGet();
assert.match(
cmd,
/^ssh -o IdentityFile='.*agenthub-ssh\/id_ed25519' -o IdentityFile='.*id_rsa'/
);
assert.match(cmd, /-o IdentityFile=~\/\.ssh\/id_ed25519/);
// ssh itself reads it: ours first, then the defaults.
const ids = execFileSync(
"sh",
["-c", `${cmd.replace(/^ssh /, "ssh -G -F /dev/null ")} x.test`],
{
encoding: "utf8",
}
)
.split("\n")
.filter((l) => l.startsWith("identityfile "));
assert.match(ids[0], /agenthub-ssh\/id_ed25519$/);
assert.ok(ids.some((l) => l.endsWith("/.ssh/id_ed25519")));
// The dashboard's own fetch still gets its BatchMode command over ours.
const off = await request("PUT", "/api/me/git/ssh-terminal", { on: false });
assert.equal(off.body.ssh.terminal, "off");
assert.equal(gitGet(), "");
});
it("never overwrites a core.sshCommand the user wrote", async () => {
execFileSync("git", ["config", "--global", "core.sshCommand", "ssh -i ~/mine"]);
try {
const r = await request("PUT", "/api/me/git/ssh-terminal", { on: true });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "EFOREIGNSSH");
const off = await request("PUT", "/api/me/git/ssh-terminal", { on: false });
assert.equal(off.body.ssh.terminal, "foreign");
assert.equal(gitGet(), "ssh -i ~/mine");
} finally {
execFileSync("git", ["config", "--global", "--unset", "core.sshCommand"]);
}
});
});
describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
const getUrl = (url) =>
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
const HOST = "git-codecommit.ap-southeast-1.amazonaws.com";
it("makes a plain ssh:// URL log in as the saved user, and only for that host", async () => {
const r = await request("PUT", "/api/me/git/ssh-user", {
host: ` ${HOST.toUpperCase()} `,
user: "APKAEXAMPLE1",
});
assert.equal(r.status, 200);
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.
const again = await request("PUT", "/api/me/git/ssh-user", {
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, []);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://${HOST}/v1/repos/x`);
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${HOST}`)).status, 404);
});
it("refuses a bad host or user, and leaves the user's own insteadOf alone", async () => {
const own = "url.ssh://me@mine.test/.insteadOf";
execFileSync("git", ["config", "--global", own, "ssh://mine.test/"]);
try {
assert.equal(
(await request("PUT", "/api/me/git/ssh-user", { host: "-oProxy", user: "a" })).body.error
.code,
"EBADHOST"
);
const bad = await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "a@b" });
assert.equal(bad.status, 400);
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", 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 {
execFileSync("git", ["config", "--global", "--unset", own]);
}
});
});
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");
@@ -356,7 +613,12 @@ describe("git-identity, member account (S3, stubbed)", () => {
it("keeps the key and the logins in the member's own home", async () => {
const account = identity.accountForDir(path.join(HOME, "proj"));
assert.equal(account.kind, "member");
calls = [];
await identity.generateSshKey(account);
// A member's terminal git uses the key from the start (issue #82).
const set = calls.find((c) => c.args[2] === "core.sshCommand");
assert.equal(set.cwd, HOME);
assert.match(set.args[3], /IdentityFile='.*\/users\/an\/\.config\/agenthub\/ssh\/id_ed25519'/);
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
@@ -381,7 +643,11 @@ describe("git-identity, member account (S3, stubbed)", () => {
args[i - 1],
/^core\.sshCommand=ssh -i '.*\/\.config\/agenthub\/ssh\/id_ed25519'.*BatchMode=yes/
);
assert.match(args[i + 2], /^core\.sshCommand=ssh -i /);
// Persisted by file name, both types: a key made later is offered too.
assert.match(
args[i + 2],
/^core\.sshCommand=ssh -o IdentityFile='.*\/id_ed25519' -o IdentityFile='.*\/id_rsa' -o IdentitiesOnly=yes$/
);
assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
});
+142 -1
View File
@@ -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"))
);
});
});
+70
View File
@@ -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();
}
}
);
+186
View File
@@ -0,0 +1,186 @@
/**
* @file Tests for each member's own tmux server on the Users page (S3,
* server/lib/member-tmux.js + `/api/users/tmux*`): its version beside the
* installed one, an admin's restart of one member (password again, audited,
* ETMUXSTUCK when it will not stop) and of every idle outdated member — a
* member whose agent is working is skipped. Member tmux calls are faked.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "member-tmux-"));
const USERS = path.join(TMP, "users");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const tmux = require("../lib/tmux");
let server;
let BASE;
const ids = {};
const BOSS_PASS = "admin-pass-1";
// Fake member tmux servers, by Linux login.
const servers = {};
function fakeMember(acct, args) {
const s = servers[acct.login];
if (!s || !s.alive) throw Object.assign(new Error("no server running"), { status: 1 });
if (args[0] === "display-message") return `${s.version}\t${s.pid}\t1700000000\t${s.sessions}\n`;
if (args[0] === "kill-server") {
if (s.stuck) throw Object.assign(new Error("server exited unexpectedly"), { status: 1 });
s.alive = false;
return "";
}
if (args[0] === "list-sessions") return "";
return "";
}
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
process.env.AGENTHUB_USERS_ROOT = USERS;
fs.mkdirSync(USERS, { recursive: true });
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.zed = (await auth.createUser("zed", "member-pass-1", "developer")).id;
ids.ann = (await auth.createUser("ann", "member-pass-1", "developer")).id;
ids.stu = (await auth.createUser("stu", "member-pass-1", "developer")).id;
// zed's agent is working right now.
db.prepare(
"INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'running')"
).run(path.join(USERS, "zed", "p"), ids.zed);
// S3 on: a server.conf whose TMUX_BIN answers 3.8, and tmux-member installed.
const bin = path.join(TMP, "tmux");
fs.writeFileSync(bin, "#!/bin/sh\necho 'tmux 3.8'\n", { mode: 0o755 });
const wrapper = path.join(TMP, "tmux-member");
fs.writeFileSync(wrapper, "#!/bin/sh\n", { mode: 0o755 });
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nTMUX_BIN=${bin}\nGIT_BIN=/usr/bin/git\n`
);
linux.__setTmuxMember(wrapper);
Object.assign(servers, {
"ah-mia": { version: "3.6", pid: 11, sessions: 2, alive: true },
"ah-zed": { version: "3.6", pid: 12, sessions: 1, alive: true },
"ah-ann": { version: "3.8", pid: 13, sessions: 3, alive: true },
"ah-stu": { version: "3.6", pid: 14, sessions: 1, alive: true, stuck: true },
});
tmux.__setAccountImpl(() => null, fakeMember);
});
after(() => {
delete process.env.AGENTHUB_MODE;
delete process.env.AGENTHUB_USERS_ROOT;
delete process.env.AGENTHUB_SERVER_CONF;
linux.__setTmuxMember("/opt/agenthub/libexec/tmux-member");
linux.__resetConfCache();
tmux.__reset();
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("member tmux servers on the Users page", () => {
it("lists each member's version beside the installed one, and who is working", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("GET", "/api/users/tmux", { cookie: boss });
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.installed, "3.8");
assert.equal(r.body.matched, true);
const by = Object.fromEntries(r.body.users.map((u) => [u.username, u]));
assert.equal(by.mia.version, "3.6");
assert.equal(by.mia.outdated, true);
assert.equal(by.mia.sessions, 2);
assert.equal(by.zed.busy, 1);
assert.equal(by.ann.outdated, false);
// boss (no server of theirs) is listed as not running.
assert.equal(by.boss.running, false);
});
it("is staff-only to read and an admin's to restart", async () => {
const mia = await login("mia", "member-pass-1");
assert.equal((await call("GET", "/api/users/tmux", { cookie: mia })).status, 403);
const r = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
cookie: mia,
body: { currentPassword: "member-pass-1" },
});
assert.equal(r.status, 403);
assert.equal(servers["ah-ann"].alive, true);
});
it("restarts one member's server with the admin's password, audited", async () => {
const boss = await login("boss", BOSS_PASS);
const wrong = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
cookie: boss,
body: { currentPassword: "nope" },
});
assert.equal(wrong.body.error.code, "EREAUTH");
assert.equal(servers["ah-ann"].alive, true);
const ok = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(ok.status, 200);
assert.equal(servers["ah-ann"].alive, false);
const audit = db
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
.get(ids.ann);
assert.match(audit.path, /ann: tmux restarted \(was 3\.8, 3 session/);
});
it("names the pid to kill as root when a server will not stop", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", `/api/users/${ids.stu}/tmux-restart`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ETMUXSTUCK");
assert.match(r.body.error.message, /sudo kill -9 14/);
});
it("restarts every idle outdated member, skipping one whose agent works", async () => {
servers["ah-ann"] = { version: "3.8", pid: 13, sessions: 1, alive: true };
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", "/api/users/tmux-restart-idle", {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(r.status, 200);
assert.deepEqual(r.body.restarted, ["mia"]);
assert.deepEqual(r.body.skipped, ["zed"]);
assert.equal(r.body.failed[0].username, "stu");
assert.equal(servers["ah-mia"].alive, false);
assert.equal(servers["ah-zed"].alive, true);
assert.equal(servers["ah-ann"].alive, true); // already on 3.8
});
});
+10 -9
View File
@@ -3,9 +3,10 @@
* an install its browser run console cannot work, because every run the
* dashboard starts is a tmux session (server/lib/tmux.js). The row must WARN
* and never FAIL: sessions, analytics, lanes and the CLI all work without the
* binary, so failing a doctor run over it would cry wolf. A tmux older than 3.7
* (no pane scrollbars) also warns, as does a new binary still talking to an
* older running server. tmux is faked through the module's own exec seam, so
* binary, so failing a doctor run over it would cry wolf. A tmux older than its
* platform asks for (tmux.upgradeAdvice: psmux 3.4.0 on Windows, tmux 3.7
* elsewhere) also warns, as does a new binary still talking to an older
* running server. tmux is faked through the module's own exec seam, so
* the test never depends on the host having it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -56,18 +57,18 @@ describe("plugin-doctor: tmux row", () => {
assert.equal(rowWithTmux("next-3.8", "3.8-rc3").level, "ok");
});
it("warns, with the upgrade path, when tmux is older than 3.7", () => {
const row = rowWithTmux("3.4", "3.4");
it("warns, with this platform's upgrade path, when tmux is older than it asks for", () => {
// 2.9 is too old everywhere; the advice (minimum, command) is per platform.
const row = rowWithTmux("2.9", "2.9");
assert.equal(row.level, "warn");
assert.match(row.detail, /3\.7/);
assert.match(row.detail, /brew upgrade tmux/);
assert.ok(row.detail.includes(tmux.upgradeAdvice("2.9")), row.detail);
});
it("warns when a new tmux is still talking to an older running server", () => {
const row = rowWithTmux("3.7c", "3.4");
const row = rowWithTmux("3.7c", "2.9");
assert.equal(row.level, "warn");
assert.match(row.detail, /server is 3\.4/);
assert.match(row.detail, /server is 2\.9/);
});
});
+19 -1
View File
@@ -116,7 +116,8 @@ describe("pty-attach", () => {
it("wires PTY data to binary WS frames and WS binary frames to PTY writes", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
// Linux: a Windows attach also says `wheel` first (its own test below).
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24, platform: "linux" });
fakePty.__emitter.emit("data", "hello from claude");
assert.equal(ws.__sent.length, 1);
@@ -269,4 +270,21 @@ describe("attachBinary on Windows", () => {
}
tmux.__reset();
});
it("tells a Windows attach to send the wheel itself (psmux never tracks the mouse)", () => {
for (const [platform, told] of [
["win32", true],
["linux", false],
]) {
const ws = makeFakeWs();
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24, platform });
const said = ws.__sent.filter((m) => !m.binary).map((m) => JSON.parse(m.data));
assert.equal(
said.some((m) => m.type === "wheel"),
told,
platform
);
ptyAttach.__resetGroups();
}
});
});
+37
View File
@@ -173,6 +173,34 @@ describe("/api/run", () => {
assert.deepEqual(body, { ok: true });
});
it("DELETE /:id?archive=1 archives the run's session; a plain DELETE (Sleep) keeps it", async () => {
const { db } = require("../db");
db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-stop", TMP);
db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-sleep", TMP);
const run = db.prepare(
"INSERT OR REPLACE INTO dashboard_runs (id, session_id, cwd, status) VALUES (?, ?, ?, 'running')"
);
const archivedAt = (id) =>
db.prepare("SELECT archived_at FROM sessions WHERE id = ?").get(id).archived_at;
tmux.__setExecImpl(() => "");
run.run("agenthub-lane-9-1", "sess-sleep", TMP);
const slept = await fetchJson("/api/run/agenthub-lane-9-1", { method: "DELETE" });
assert.deepEqual(slept.body, { ok: true });
assert.equal(archivedAt("sess-sleep"), null);
run.run("agenthub-lane-9-2", "sess-stop", TMP);
const stopped = await fetchJson("/api/run/agenthub-lane-9-2?archive=1", { method: "DELETE" });
assert.equal(stopped.status, 200);
assert.deepEqual(stopped.body, { ok: true, archived: "sess-stop" });
assert.ok(archivedAt("sess-stop"));
// A pane with no known session (a shell) archives nothing.
run.run("agenthub-lane-9-3", null, TMP);
const shell = await fetchJson("/api/run/agenthub-lane-9-3?archive=1", { method: "DELETE" });
assert.deepEqual(shell.body, { ok: true, archived: null });
});
it("DELETE /:id returns 404 for a session that doesn't exist", async () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
@@ -190,6 +218,15 @@ describe("/api/run", () => {
tmux.__setExecImpl(() => "tmux 3.4");
const { body } = await fetchJson("/api/run/tmux");
assert.equal(body.available, true);
// The version and this platform's upgrade advice (null when new enough).
assert.equal(body.version, "3.4");
assert.equal(body.upgrade, tmux.upgradeAdvice("3.4"));
});
it("GET /tmux says to upgrade a version older than the platform asks for", async () => {
tmux.__setExecImpl(() => "tmux 2.9");
const { body } = await fetchJson("/api/run/tmux");
assert.match(body.upgrade, /older than/);
});
it("GET /cwds still returns suggested directories (unchanged behavior)", async () => {
+12 -1
View File
@@ -240,9 +240,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
process.env.AGENTHUB_SERVER_CONF,
`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"
+3 -1
View File
@@ -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, []);
});
+70
View File
@@ -326,4 +326,74 @@ describe("tmux wrapper", () => {
});
assert.equal(tmux.isTmuxAvailable(), false);
});
it("upgradeAdvice asks each platform for its own minimum, with its own command", () => {
// Windows: psmux before 3.4.0 (the WinGet `tmux`).
assert.match(
tmux.upgradeAdvice("3.3.2", "win32"),
/psmux 3\.3\.2 .*3\.4\.0.*winget upgrade marlocarlo\.psmux/
);
assert.equal(tmux.upgradeAdvice("3.4.0", "win32"), null);
assert.equal(tmux.upgradeAdvice("3.10.1", "win32"), null);
// macOS / Linux: tmux before 3.7 (pane scrollbars).
assert.match(tmux.upgradeAdvice("3.4", "darwin"), /brew upgrade tmux/);
assert.match(tmux.upgradeAdvice("3.4", "linux"), /github\.com\/tmux\/tmux\/releases/);
assert.equal(tmux.upgradeAdvice("3.7c", "linux"), null);
assert.equal(tmux.upgradeAdvice("next-3.8", "darwin"), null);
// Nothing to compare: no advice rather than a wrong one.
assert.equal(tmux.upgradeAdvice(null, "linux"), null);
assert.equal(tmux.upgradeAdvice("3.3.2", "freebsd"), null);
});
});
describe("our own tmux server: bounded and version-matched", () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
beforeEach(() => tmux.__reset());
it("backs off after a call to our server times out, instead of blocking every poll", () => {
let calls = 0;
tmux.__setExecImpl(() => {
calls++;
throw Object.assign(new Error("spawnSync tmux ETIMEDOUT"), { code: "ETIMEDOUT" });
});
assert.equal(tmux.hasSession("agenthub-lane-1"), false);
const after = calls;
assert.ok(after >= 1);
// Meanwhile nothing reaches tmux: the server is left alone for a minute.
assert.equal(tmux.hasSession("agenthub-lane-1"), false);
assert.equal(tmux.serverPid(), null);
assert.equal(calls, after);
});
it("finds the binary of the server listening on our socket from /proc alone", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tmux-proc-"));
const sock = "/tmp/tmux-1000/default";
fs.mkdirSync(path.join(root, "net"));
fs.writeFileSync(
path.join(root, "net", "unix"),
"Num RefCount Protocol Flags Type St Inode Path\n" +
`0000: 00000003 00000000 00000000 0001 03 777 ${sock}\n` + // a client's
`0000: 00000002 00000000 00010000 0001 01 4242 ${sock}\n` // the listener
);
const proc = (pid, comm, fds) => {
fs.mkdirSync(path.join(root, pid, "fd"), { recursive: true });
fs.writeFileSync(path.join(root, pid, "comm"), `${comm}\n`);
fs.symlinkSync(process.execPath, path.join(root, pid, "exe"));
fds.forEach((t, i) => fs.symlinkSync(t, path.join(root, pid, "fd", String(i))));
};
proc("10", "bash", ["socket:[4242]"]); // not tmux
proc("11", "tmux: client", ["socket:[777]"]);
proc("12", "tmux: server", ["/dev/null", "socket:[4242]"]);
try {
assert.equal(
tmux.serverExeFor(sock, { procRoot: root, uid: null }),
path.join(root, "12", "exe")
);
assert.equal(tmux.serverExeFor("/tmp/other", { procRoot: root, uid: null }), null);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+2
View File
@@ -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
View File
@@ -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]);
+2
View File
@@ -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"];
+97 -7
View File
@@ -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,
+391 -40
View File
@@ -5,7 +5,7 @@
*
* Whose identity is decided by an ACCOUNT, never by the request:
* - a member (S3, `run-as.js`): everything lives in their own home, under
* `~/.config/agenthub/` — `ssh/id_ed25519` or `ssh/id_rsa` (+ `.pub`) and `git-credentials`
* `~/.config/agenthub/` — `ssh/id_ed25519` and/or `ssh/id_rsa` (+ `.pub`) and `git-credentials`
* (git's own `credential-store` format) — written only AS the member
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
* Their git runs as them, so it reads these itself; no secret ever crosses
@@ -20,6 +20,17 @@
* repo's own config (`git clone -c`): `core.sshCommand` naming the key, or a
* `credential.helper=store --file=<the member's store>` — so an agent's push
* from a pane uses the same login without touching the user's global config.
* 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 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>
*/
@@ -64,10 +75,17 @@ function accountForRequest(req) {
/**
* The key types offered: ed25519 (the default — GitHub, GitLab, Gitea…) and
* RSA 4096 for hosts that refuse ed25519 (AWS IAM / CodeCommit takes only
* `ssh-rsa`). One key per account at a time, in the file of its type.
* `ssh-rsa`). An account may hold one of EACH, in the file of its type, and
* every ssh git runs offers both (ed25519 first).
*/
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") {
@@ -208,38 +226,81 @@ function fingerprint(publicKey) {
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
}
/** `{type, file}` of the account's key (ed25519 first), or null. */
function currentKey(account) {
/**
* `[{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);
for (const type of Object.keys(KEY_FILES)) {
const file = path.join(sshDir, KEY_FILES[type]);
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 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;
return { type, file, publicKey: publicKey.trim() };
keys.push({ id, type, name, file, publicKey: publicKey.trim() });
}
return null;
return keys;
}
/** The private key file a clone or fetch uses, or null. */
const keyFile = (account) => currentKey(account)?.file ?? null;
/** The private key files a clone or fetch offers (ed25519 first), maybe none. */
const keyFiles = (account) => currentKeys(account).map((k) => k.file);
/** `{type, publicKey, fingerprint}` of the account's key, or null — never the private half. */
function sshKeyInfo(account) {
const k = currentKey(account);
return k ? { type: k.type, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey) } : null;
/** ssh options naming `files` — `-i` each, in order. */
const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join("");
/** `[{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),
}));
}
/** The account's first key (ed25519 before rsa), or null — what `ssh` meant before two. */
const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null;
/**
* Make the account's key (`type`: ed25519, the default, or rsa); an existing
* one is replaced only with `replace` — and a key of the other type goes, so
* there is one key per account.
* 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 (sshKeyInfo(account) && !replace) {
throw err("EEXISTS", "an 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")
@@ -248,25 +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 });
for (const [other, name] of Object.entries(KEY_FILES)) {
if (other === type) continue;
const f = path.join(sshDir, name);
if (isOwnFile(account, f) || readText(account, `${f}.pub`) != null) {
removeFile(account, f);
removeFile(account, `${f}.pub`);
}
// 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) — once on, it follows the keys.
if (account.kind === "member" || (await terminalSshState(account)) === "on") {
await setTerminalSsh(account, true).catch(() => {});
}
return sshKeyInfo(account);
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
}
function removeSshKey(account) {
const k = currentKey(account);
if (!k) return false;
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
/**
* 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, which) {
const { type, id } = typeof which === "string" ? { type: which } : which || {};
if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
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;
}
@@ -366,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
@@ -386,6 +475,246 @@ async function setAuthor(account, { name, email }) {
return getAuthor(account);
}
// ── The key in the account's own terminal (`~/.gitconfig` core.sshCommand) ──
/**
* The `core.sshCommand` that makes a plain `git` in the account's terminal
* use the AgentHub key (issue #82: a clone typed in a pane offered only
* `~/.ssh/id_*` and was refused). Both key files are named, so swapping the
* key's type needs no rewrite; ssh skips a missing `IdentityFile` silently.
* Naming one drops ssh's default list, so the defaults are named again after
* ours — a key the user set up in `~/.ssh/` keeps working.
*/
function terminalSshCommand(account) {
const { sshDir } = paths(account);
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");
return git(homeOf(account), ["config", "--global", "--get", "core.sshCommand"]).then(
(r) => r.stdout.trim(),
() => ""
);
}
/**
* Whether the account's terminal git uses the AgentHub key: `on` (our
* sshCommand), `off` (none set) or `foreign` (one the user wrote — never
* touched).
*/
async function terminalSshState(account) {
const cmd = await globalSshCommand(account);
if (!cmd) return "off";
return isOurTerminal(account, cmd) ? "on" : "foreign";
}
/**
* Turn the AgentHub key on/off for the account's terminal git. On refuses
* over a sshCommand the user wrote (`EFOREIGNSSH`); off removes only ours.
* Neither value is a secret, so it may cross sudo's argv.
*/
async function setTerminalSsh(account, on) {
const { git } = require("./worktree");
const state = await terminalSshState(account);
if (on) {
if (state === "foreign") {
throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is");
}
// 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",
"core.sshCommand",
terminalSshCommand(account),
]);
}
} else if (state === "on") {
await git(homeOf(account), ["config", "--global", "--unset", "core.sshCommand"]);
}
return terminalSshState(account);
}
// ── SSH login name per host (`~/.gitconfig` url.<…>.insteadOf) ─────────────
// AWS CodeCommit logs an SSH key in by its IAM key ID (`APKA…`) as the user
// name, so `ssh://git-codecommit…/v1/repos/x` (no user) is refused. One
// `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` per host makes the
// plain URL work for every git of the account — a clone typed in a pane, the
// 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}/`;
/** `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), [
"config",
"--global",
"--get-regexp",
"^url\\..*\\.insteadof$",
]).then(
(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] || 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 `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 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",
"--global",
"--fixed-value",
"--unset-all",
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
`ssh://${f}/`,
]);
}
if (rows.length) await unlinkKey(account, f);
return rows.length > 0;
}
/**
* 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, . _ -");
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://${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) ─────────
/**
@@ -409,17 +738,18 @@ async function sshConfigFor(dir) {
(r) => r.stdout.trim(),
() => ""
);
if (own) return [];
let account;
try {
account = accountForDir(dir);
} catch {
return [];
}
const key = keyFile(account);
// 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 && !isOurTerminal(account, own)) return [];
return [
"-c",
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
`core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
];
}
@@ -526,12 +856,25 @@ 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 key = keyFile(account);
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
// 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 (key) persist.push("-c", `core.sshCommand=${base}`);
if (keys.length) {
// 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 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") {
const k = hostKey("https", remote.host);
const before = readStore(account, { strict: true });
@@ -617,8 +960,15 @@ module.exports = {
makeRsaKeyPair,
fingerprint,
sshKeyInfo,
sshKeysInfo,
generateSshKey,
removeSshKey,
terminalSshCommand,
terminalSshState,
setTerminalSsh,
listSshUsers,
setSshUser,
removeSshUser,
listHttps,
saveHttps,
removeHttps,
@@ -626,6 +976,7 @@ module.exports = {
defaultName,
cloneRepo,
getAuthor,
defaultEmail,
setAuthor,
sshConfigFor,
memberHttpsConfig,
+417
View File
@@ -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 };
+23 -3
View File
@@ -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,
+103
View File
@@ -0,0 +1,103 @@
/**
* @file member-tmux.js
* @description Server mode, one Linux user per account (S3): each member's
* own tmux server as the Users page shows it — the version it runs next to
* the installed one (`TMUX_BIN`), how many sessions it holds and whether an
* agent of theirs is working — and an admin's restart of it. A tmux server
* keeps the version it started with until its last session closes, so after
* a tmux upgrade an old server lingers; `tmux-member` (install-system) keeps
* every call on it matched to its own binary, and this is how an admin moves
* a member onto the new version on purpose. A restart ends every pane of
* theirs (their Claude sessions resume from the workspace).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFileSync } = require("node:child_process");
const { db } = require("../db");
const linux = require("./linux-accounts");
const tmux = require("./tmux");
const err = (code, message) => Object.assign(new Error(message), { code });
// The installed tmux's version (`3.8`), re-read at most once a minute.
let installed = { at: 0, bin: null, version: null };
function installedVersion() {
const bin = linux.serverConf()?.tmuxInstalled || null;
if (installed.bin === bin && Date.now() - installed.at < 60_000) return installed.version;
let version = null;
if (bin) {
try {
version =
String(execFileSync(bin, ["-V"], { encoding: "utf8", timeout: 3000, stdio: "pipe" }))
.trim()
.replace(/^tmux\s+/, "") || null;
} catch {
/* missing or broken: unknown */
}
}
installed = { at: Date.now(), bin, version };
return version;
}
/** How many of `userId`'s workspaces have an agent working right now. */
function busyCount(userId) {
return db
.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ? AND status = 'running'")
.get(userId).n;
}
/** One user's row; null for an account with no Linux side. */
function rowFor(user, installedV) {
const acct = linux.accountFor(user.username);
if (!acct) return null;
const info = tmux.memberServerInfo(acct);
return {
id: user.id,
username: user.username,
...info,
busy: busyCount(user.id),
outdated: !!(info.running && info.version && installedV && info.version !== installedV),
};
}
/** `{available, installed, matched, users: [...]}` for the Users page. */
function overview(users) {
if (!linux.s3Enabled()) return { available: false, users: [] };
const conf = linux.serverConf();
const v = installedVersion();
return {
available: true,
installed: v,
// Whether calls on an older server already use its own binary.
matched: conf?.bins?.tmux !== conf?.tmuxInstalled,
users: users.map((u) => rowFor(u, v)).filter(Boolean),
};
}
/**
* Restart `user`'s tmux server. `{restarted: false}` when none ran; throws
* ETMUXSTUCK (with the pid) when it would not stop — root has to kill it.
*/
function restart(user) {
const acct = linux.accountFor(user.username);
if (!acct) throw err("ENOS3", "this account has no Linux user (S3 is not installed)");
const before = tmux.memberServerInfo(acct);
if (!before.running) return { restarted: false, before };
try {
tmux.killMemberServer(acct);
} catch {
/* judged by what is left below */
}
const after = tmux.memberServerInfo(acct);
if (after.running) {
const pid = before.pid || after.pid;
throw err(
"ETMUXSTUCK",
`${user.username}'s tmux server did not stop — run \`sudo kill -9 ${pid || "<pid>"}\` on the server`
);
}
return { restarted: true, before };
}
module.exports = { overview, restart, installedVersion };
+5
View File
@@ -136,6 +136,11 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
group.viewers.add(me);
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
else group.driver = me;
// psmux never turns mouse tracking on for its client (not even for a pane
// app that asked), so xterm.js would never report the wheel and the pane
// could not scroll. psmux does act on a wheel report it receives: the
// browser sends one itself (TerminalView).
if (platform === "win32") tell(me, { type: "wheel" });
// Backpressure: a slow viewer (a remote browser on a thin link) must not
// queue a chatty pane's output in this process without bound. Past 1 MB
+200 -11
View File
@@ -27,10 +27,16 @@
* a member's runs in the background and a poll reads its last answer.
* With no member (local mode, S3 not installed, the host's own workspaces)
* nothing changes.
* Calls on our OWN server are bounded like a member's (5 s, then a minute's
* back-off) and run that server's own binary (`/proc/<pid>/exe`, found from
* its socket), so a tmux upgraded under it never mixes client and server
* versions — a 3.8 client spun a 3.6 server at 100% CPU.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFile, execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
// Test seam: swap the exec implementation so unit tests never invoke a real
// tmux binary. Mirrors run-spawner.js's __injectChildForTest/__reset style.
@@ -40,8 +46,88 @@ const { execFile, execFileSync } = require("node:child_process");
// journal on every poll while no tmux server is running yet.
// cwd "/": a tmux server is forked from the call that starts it and keeps
// that cwd; once it is deleted, tmux ignores every pane's `-c` (issue #69).
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], cwd: "/" };
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
// Bounded like a member's call (below): our server can spin too — a 3.6
// server asked by a 3.8 client sat at 100% CPU, and one unbounded
// `has-session` froze the whole dashboard for five minutes.
const HOST_TIMEOUT_MS = 5000;
const EXEC_OPTS = {
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
cwd: "/",
timeout: HOST_TIMEOUT_MS,
killSignal: "SIGKILL",
maxBuffer: 1 << 20,
};
/**
* The binary that started the tmux server listening on `socket`
* (`/proc/<pid>/exe`, which still runs after an upgrade replaced the file),
* or null. A client must match its server: tmux upgraded under a running
* server left PATH's newer client talking to the old server, which spun at
* 100% CPU on it. Found from /proc alone — asking tmux would BE that call.
* `procRoot`/`uid` are test seams.
*/
function serverExeFor(socket, { procRoot = "/proc", uid = process.getuid?.() } = {}) {
let inode = null;
try {
for (const line of fs.readFileSync(path.join(procRoot, "net", "unix"), "utf8").split("\n")) {
// Num RefCount Protocol Flags Type St Inode Path — Flags 00010000 = listening.
const f = line.trim().split(/\s+/);
if (f.length >= 8 && f[3] === "00010000" && f.slice(7).join(" ") === socket) {
inode = f[6];
break;
}
}
} catch {
return null;
}
if (!inode) return null;
const want = `socket:[${inode}]`;
for (const pid of fs.readdirSync(procRoot)) {
if (!/^\d+$/.test(pid)) continue;
const dir = path.join(procRoot, pid);
try {
if (!fs.readFileSync(path.join(dir, "comm"), "utf8").startsWith("tmux")) continue;
if (uid != null && fs.statSync(dir).uid !== uid) continue;
for (const fd of fs.readdirSync(path.join(dir, "fd"))) {
if (fs.readlinkSync(path.join(dir, "fd", fd)) !== want) continue;
const exe = path.join(dir, "exe");
fs.accessSync(exe, fs.constants.X_OK);
return exe;
}
} catch {
/* gone, or not ours to read */
}
}
return null;
}
/** Our default socket, as tmux itself picks it (no -L/-S). */
function hostSocket() {
if (process.env.TMUX) return process.env.TMUX.split(",")[0];
const dir = process.env.TMUX_TMPDIR || "/tmp";
return path.join(dir, `tmux-${process.getuid()}`, "default");
}
// The binary every call on OUR server runs, re-read every HOST_BIN_TTL_MS.
// PATH's tmux when no server runs (that call starts one), off Linux, and for
// `-V` (the installed client's version, for the upgrade advice).
const HOST_BIN_TTL_MS = 10_000;
let hostBin = { at: 0, bin: "tmux" };
function hostTmuxBin() {
if (process.platform !== "linux") return "tmux";
if (Date.now() - hostBin.at < HOST_BIN_TTL_MS) return hostBin.bin;
let bin = "tmux";
try {
bin = serverExeFor(hostSocket()) || "tmux";
} catch {
/* no /proc: PATH's */
}
hostBin = { at: Date.now(), bin };
return bin;
}
let execImpl = (args, opts = EXEC_OPTS) =>
execFileSync(args[0] === "-V" ? "tmux" : hostTmuxBin(), args, opts);
// Linux: the tmux server lives in the cgroup of whatever started it — the
// dashboard's systemd unit, or the terminal/IDE scope a dev server runs in —
@@ -136,6 +222,21 @@ function memberCall(acct, args, input) {
throw err;
}
}
// Our own server under the same back-off: a call that timed out stops every
// call on it for MEMBER_BACKOFF_MS (ETMUXHUNG), so a spinning server costs
// the event loop one HOST_TIMEOUT_MS per minute, not one per poll.
const HOST_ACCT = { login: "host" };
function hostCall(args) {
reachable(HOST_ACCT);
try {
return execImpl(args);
} catch (err) {
noteHang(HOST_ACCT, err);
if (err.code === "ETIMEDOUT") hostBin.at = 0; // look for the server again
throw err;
}
}
/** Test seam. A sync `member` fake also answers the async listing. */
function __setAccountImpl(fn, member) {
accountImpl = fn;
@@ -159,7 +260,7 @@ function accountOf(name) {
/** Run a tmux command on the server that holds session `name`. */
function onServerOf(name, args, input) {
const acct = accountOf(name);
return acct ? memberCall(acct, args, input) : execImpl(args);
return acct ? memberCall(acct, args, input) : hostCall(args);
}
// Whether this tmux takes `=name` targets: true for tmux itself, false for
@@ -174,12 +275,15 @@ function __setExecImpl(fn, { exact = true } = {}) {
exactTargets = exact;
listed = new Map();
listGen++;
unreachable = new Map();
}
function __setScopeImpl(fn) {
scopeImpl = fn;
}
function __reset() {
execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
execImpl = (args, opts = EXEC_OPTS) =>
execFileSync(args[0] === "-V" ? "tmux" : hostTmuxBin(), args, opts);
hostBin = { at: 0, bin: "tmux" };
scopeImpl = realScopeImpl;
accountImpl = (name) => require("./run-as").accountForSession(name);
memberImpl = realMemberImpl;
@@ -217,7 +321,7 @@ function listOutput(acct = null) {
return hit.out;
}
try {
const out = sessionNames(execImpl(LIST_ARGS));
const out = sessionNames(hostCall(LIST_ARGS));
listed.set("", { at: Date.now(), out });
return out;
} catch (err) {
@@ -324,7 +428,7 @@ function exact() {
if (exactTargets !== null) return exactTargets;
let first;
try {
first = sessionNames(execImpl(LIST_ARGS))
first = sessionNames(hostCall(LIST_ARGS))
.split("\n")
.map((x) => x.trim())
.find(Boolean);
@@ -334,7 +438,7 @@ function exact() {
if (!first) return true;
const answers = (t) => {
try {
execImpl(["has-session", "-t", t]);
hostCall(["has-session", "-t", t]);
return true;
} catch {
return false;
@@ -379,7 +483,7 @@ function hasSession(name) {
/** Our running tmux server's pid, or null when none answers. */
function serverPid() {
try {
const pid = String(execImpl(["display-message", "-p", "#{pid}"])).trim();
const pid = String(hostCall(["display-message", "-p", "#{pid}"])).trim();
return /^\d+$/.test(pid) ? pid : null;
} catch {
return null;
@@ -435,10 +539,10 @@ function newSession({ name, cwd, argv, env }) {
scopeImpl(args);
} catch {
// No user manager (SSH without linger, no systemd): the plain start.
if (!hasSession(name)) execImpl(args);
if (!hasSession(name)) hostCall(args);
}
} else {
execImpl(args);
hostCall(args);
}
try {
enableMouse(name);
@@ -616,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 = [];
@@ -695,11 +846,45 @@ function clientVersion() {
}
}
// The oldest tmux AgentHub asks for, and how each platform upgrades it.
// Windows' is psmux: before 3.4.0 a dashboard terminal stalled until a
// refresh, and fixes for frame push, writes and synchronized output landed in
// 3.3.6–3.4.0. Elsewhere 3.7, the first with pane scrollbars (enableMouse).
const UPGRADES = {
win32: {
min: [3, 4, 0],
name: "psmux",
how: "close every AgentHub terminal pane, then run: winget upgrade marlocarlo.psmux",
},
darwin: { min: [3, 7, 0], name: "tmux", how: "brew upgrade tmux" },
linux: {
min: [3, 7, 0],
name: "tmux",
how:
"distros ship older ones (Ubuntu 24.04 has 3.4), so build the release from " +
"https://github.com/tmux/tmux/releases (needs libevent-dev libncurses-dev bison: " +
"./configure && make && sudo make install)",
},
};
/** What to tell the user when `version` is older than their platform asks
* for; null when it is new enough or cannot be compared. Advice only —
* AgentHub never installs or restarts tmux. */
function upgradeAdvice(version = clientVersion(), platform = process.platform) {
const want = UPGRADES[platform];
const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(version || "");
if (!want || !m) return null;
const have = [m[1], m[2], m[3] || 0].map(Number);
const i = have.findIndex((n, k) => n !== want.min[k]);
if (i === -1 || have[i] > want.min[i]) return null;
return `${want.name} ${version} is older than ${want.min.join(".")} — ${want.how}`;
}
/** The RUNNING server's version, null when none runs. A server started before
* an upgrade keeps its old version until every tmux session has closed. */
function serverVersion() {
try {
return execImpl(["display-message", "-p", "#{version}"]).trim() || null;
return hostCall(["display-message", "-p", "#{version}"]).trim() || null;
} catch {
return null;
}
@@ -707,6 +892,7 @@ function serverVersion() {
module.exports = {
__setScopeImpl,
serverExeFor,
session,
pane,
hasSession,
@@ -725,8 +911,11 @@ module.exports = {
isTmuxAvailable,
clientVersion,
serverVersion,
upgradeAdvice,
serverPid,
memberAttach,
memberServerInfo,
killMemberServer,
__setExecImpl,
__setAccountImpl,
__settle,
+73
View File
@@ -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"],
+65 -5
View File
@@ -268,7 +268,7 @@ const paths = {
responses: {
200: {
description:
"{account, writable, ssh: {publicKey, fingerprint} | null, https: [...], author: {name, email}}",
"{account, writable, ssh: {type, publicKey, fingerprint, terminal} | null, https: [...], author: {name, email}}",
},
},
},
@@ -276,18 +276,78 @@ const paths = {
"/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: json({ replace: { type: "boolean" } }),
responses: { 201: { description: "The identity." } },
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." },
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: json({ on: { type: "boolean" } }, ["on"]),
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: 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"],
+14
View File
@@ -585,6 +585,12 @@ const schemas = {
required: ["ok"],
properties: {
ok: { type: "boolean", 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).",
},
},
},
};
@@ -1188,6 +1194,14 @@ const paths = {
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: {
+87 -14
View File
@@ -1,16 +1,22 @@
/**
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`),
* mounted at `/api/me/git`: the SSH key a clone uses (make, replace, delete —
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete —
* `hasToken` only, never a password), 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");
@@ -27,6 +33,12 @@ const STATUS = {
EBADAUTHOR: 400,
ESTOREUNREADABLE: 409,
EBADKEYTYPE: 400,
EFOREIGNSSH: 409,
EBADSSHUSER: 400,
EBADKEYNAME: 400,
ETOOMANYKEYS: 409,
EBADALIAS: 400,
ENOKEY: 400,
};
function fail(res, e) {
@@ -35,16 +47,27 @@ 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 {
account: account.kind,
writable: account.kind === "member" || account.writable !== false,
ssh: identity.sshKeyInfo(account),
// `ssh`: the first key, as before there could be two; `sshKeys`: all.
ssh: keys[0] ? { ...keys[0], terminal } : null,
sshKeys: keys,
sshTerminal: terminal ?? null,
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);
@@ -58,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);
}
@@ -70,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);
}
@@ -81,10 +107,57 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
if (!identity.removeSshKey(account)) {
const type = req.query.type === undefined ? undefined : String(req.query.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);
}
});
/**
* Whether a plain `git` in the account's own terminal uses the AgentHub key
* (`core.sshCommand` in its `~/.gitconfig`, issue #82). `{on: boolean}`.
*/
router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
if (!identity.sshKeyInfo(account) && req.body?.on === true) {
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, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
/**
* 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, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
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, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -95,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);
}
@@ -107,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);
}
@@ -118,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
View File
@@ -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(
+23
View File
@@ -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;
+30 -1
View File
@@ -423,7 +423,14 @@ router.get("/binary", (req, res) => {
});
router.get("/tmux", (_req, res) => {
res.json({ available: tmux.isTmuxAvailable() });
// `upgrade`: this platform's advice when the binary is older than AgentHub
// asks for (tmux.upgradeAdvice), else null. The Workspace shows it.
const version = tmux.clientVersion();
res.json({
available: version !== null,
version,
upgrade: version ? tmux.upgradeAdvice(version) : null,
});
});
router.post("/", (req, res) => {
@@ -619,9 +626,31 @@ router.delete("/:id", (req, res) => {
if (!ok) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
}
// `?archive=1` is the UI's Stop: the session the pane held leaves its
// workspace's lists as well. Sleep (and the CLI) omit it, so the session
// stays listed to be resumed later. A pane with no known session (a shell,
// a Codex pane whose session was never learned) archives nothing.
if (req.query.archive === "1") {
return res.json({ ok: true, archived: archiveRunSession(req, req.params.id) });
}
return res.json({ ok: true });
});
/** Archive the session a run held; returns its id, or null when none. */
function archiveRunSession(req, runId) {
const sessionId = runs.getRun(runId)?.sessionId;
if (!sessionId || !scope.canSeeSession(req, sessionId)) return null;
const { db, stmts } = require("../db");
const changed = db
.prepare(
"UPDATE sessions SET archived_at = COALESCE(archived_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) WHERE id = ?"
)
.run(sessionId).changes;
if (!changed) return null;
require("../websocket").broadcast("session_updated", stmts.getSession.get(sessionId));
return sessionId;
}
module.exports = router;
// Shared with routes/lanes.js: lane actions spawn processes through the same
// run-spawner, so they must sit behind the same loopback/same-origin check.
+81
View File
@@ -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) => {