chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes

Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool):
- server/hub/ holds the hub-only code (devices, remote sessions, upload ingest,
  LAN gateway), required only in server mode; hub-boundary.test.js keeps local
  code from importing it.
- A local install enrolls with `agenthub remote login` or Settings → AI tools →
  Company hub; the "Company account" AI Proxy (reserved name agenthub.company)
  sends a session through the hub's gateway, which adds a pool account's token —
  no credential reaches the machine. Only those sessions are uploaded, hourly
  and at their end; secret-shaped strings are redacted on the client.
- Gateway refuses: unknown device (401), a message without a session id, an
  unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota
  (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30.
- New alert rule type remote_unmonitored: a company-account session its device
  never registered; a late registration clears the flag instead.
- The hub imports a session once per upload round (chunks carry `more`),
  not once per chunk.

Artifacts:
- Share an artifact with chosen people (artifact_shares, PATCH sharedWith,
  GET /api/artifacts/people) besides everyone; a "Shared with you" tab.
  Only the owner or an admin sees or edits who it is shared with; audited.
- Folders: each account's own arrangement (agenthub.artifacts.folders pref).
- A claude.ai page keeps a copy viewable without the publishing account: its
  HTML file (and supporting files) snapshotted at publish, or the copy Claude
  saves for a Doc / Slides / Design in artifacts-export/ (a synchronous
  PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch),
  attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or
  pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF).

Fixes:
- tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's
  tmux (stop the last pane, start again within 10 s failed with ENOENT).
- shelves: a shelf restores beside changes in other files.
- chat: a finished reply ends the turn even with no hook at all.
- Settings → AI tools: profile list display tweaks.
This commit is contained in:
2026-11-04 15:16:49 +07:00
parent 3daf7ed4cb
commit c946b73d11
95 changed files with 7918 additions and 340 deletions
@@ -0,0 +1,240 @@
/**
* @file RemoteDevices.tsx
* @description The hub's view of remote clients (server mode, server/hub/):
* a user's enrolled machines with Revoke (the viewer's own password again —
* the server checks it and writes the audit entry), and their Company-account
* sessions grouped device › folder with what the gateway counted. Shown in
* Settings for the signed-in user's own machines and on the Users page's user
* detail for an admin/analyst (`userId`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Laptop, ShieldAlert } from "lucide-react";
import { Link } from "react-router-dom";
import { api, type RemoteDevice, type RemoteSessionRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { ConfirmModal } from "../ConfirmModal";
const fmt = (n: number) =>
n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevoke: boolean }) {
const { t } = useTranslation("settings");
const [devices, setDevices] = useState<RemoteDevice[] | null>(null);
const [sessions, setSessions] = useState<RemoteSessionRow[]>([]);
const [revoking, setRevoking] = useState<RemoteDevice | null>(null);
const load = useCallback(() => {
api.remoteHub
.devices(userId)
.then((r) => setDevices(r.devices))
.catch(() => setDevices([]));
api.remoteHub
.sessions(userId)
.then((r) => setSessions(r.sessions))
.catch(() => setSessions([]));
}, [userId]);
useEffect(load, [load]);
// device › folder › sessions, the newest first (the API's order).
const groups = useMemo(() => {
const byDevice = new Map<string, Map<string, RemoteSessionRow[]>>();
for (const s of sessions) {
const d = s.deviceName;
if (!byDevice.has(d)) byDevice.set(d, new Map());
const folders = byDevice.get(d)!;
const f = s.cwd || "—";
if (!folders.has(f)) folders.set(f, []);
folders.get(f)!.push(s);
}
return [...byDevice.entries()];
}, [sessions]);
if (devices === null) return null;
return (
<div className="space-y-4" data-testid="remote-devices">
{devices.length === 0 ? (
<p className="text-xs text-fg-muted">{t("companyHub.noDevices")}</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{devices.map((d) => (
<li key={d.id} className="flex flex-wrap items-center gap-2 px-3 py-2 text-xs">
<Laptop className="h-3.5 w-3.5 text-fg-muted" />
<span className="font-medium text-fg-primary">{d.name}</span>
<span className="text-fg-muted">
{d.revokedAt
? t("companyHub.revokedAt", { time: formatDateTime(d.revokedAt) })
: d.active
? t("companyHub.validUntil", { time: formatDateTime(d.expiresAt) })
: t("companyHub.expired")}
</span>
{d.lastSeenAt && (
<span className="text-fg-muted">
·{" "}
{t("companyHub.lastSeen", {
time: formatDateTime(d.lastSeenAt),
ip: d.lastIp || "?",
})}
</span>
)}
{canRevoke && d.active && (
<button
type="button"
className="btn-ghost ml-auto h-7 px-2 text-xs text-status-danger"
onClick={() => setRevoking(d)}
>
{t("companyHub.revoke")}
</button>
)}
</li>
))}
</ul>
)}
{groups.length > 0 && (
<div className="space-y-3">
<h4 className="text-xs font-semibold text-fg-secondary">{t("companyHub.sessions")}</h4>
{groups.map(([device, folders]) => (
<div key={device} className="space-y-1.5">
<div className="text-xs font-medium text-fg-primary">{device}</div>
{[...folders.entries()].map(([folder, rows]) => (
<div key={folder} className="ml-3 space-y-1">
<div className="truncate font-mono text-[11px] text-fg-muted" title={folder}>
{folder}
</div>
<ul className="ml-3 space-y-0.5">
{rows.map((s) => (
<li
key={s.sessionId}
className="flex flex-wrap items-center gap-2 text-[11px]"
>
{s.purgedAt ? (
<span className="text-fg-muted">
{s.title || s.sessionId.slice(0, 8)}
</span>
) : (
<Link
to={`/sessions/${s.sessionId}`}
className="text-accent hover:underline"
>
{s.title || s.sessionId.slice(0, 8)}
</Link>
)}
{s.unmonitored ? (
<span className="inline-flex items-center gap-1 text-status-warning">
<ShieldAlert className="h-3 w-3" />
{t("companyHub.unmonitored")}
</span>
) : null}
<span className="text-fg-muted">
{t("companyHub.sessionLine", {
requests: s.requests,
input: fmt(s.inputTokens + s.cacheReadTokens + s.cacheWriteTokens),
output: fmt(s.outputTokens),
account: s.account || "—",
})}
</span>
<span className="text-fg-muted">
· {formatDateTime(s.lastRequestAt || s.registeredAt)}
</span>
{s.purgedAt && (
<span className="text-fg-muted">· {t("companyHub.purged")}</span>
)}
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
)}
{revoking && (
<RevokeDialog
device={revoking}
onClose={() => setRevoking(null)}
onDone={() => {
setRevoking(null);
load();
}}
/>
)}
</div>
);
}
function RevokeDialog({
device,
onClose,
onDone,
}: {
device: RemoteDevice;
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation("settings");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!password || busy) return;
setBusy(true);
setError(null);
try {
await api.remoteHub.revoke(device.id, password);
toast.success(t("companyHub.revoked", { name: device.name }));
onDone();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("companyHub.revokeTitle", { name: device.name })}
message={t("companyHub.revokeHint")}
confirmLabel={t("companyHub.revoke")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!password}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-2"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">
{t("companyHub.yourPassword")}
</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
aria-label={t("companyHub.yourPassword")}
/>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}