diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0e77b3d7a25..e38ab1cb613 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1224,6 +1224,17 @@ def _default_hermes_root_is_opt_data() -> bool: return root == _HOSTED_MANAGED_FILES_ROOT +def _dashboard_hosted_agent_mode() -> bool: + """Return true for the hosted/container dashboard layout. + + Hosted agent dashboards run with the Hermes root at ``/opt/data``. This is + the same signal the Files page uses to lock browsing to the managed data + directory, and it keeps local remote-auth dashboards from being mistaken for + hosted service instances. + """ + return _default_hermes_root_is_opt_data() + + def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy: raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip() if raw_forced_root: @@ -1654,6 +1665,7 @@ async def get_status(): "release_date": __release_date__, "config_version": current_ver, "latest_config_version": latest_ver, + "can_update_hermes": not _dashboard_hosted_agent_mode(), "gateway_running": gateway_running, "gateway_state": gateway_state, "gateway_platforms": gateway_platforms, @@ -2165,6 +2177,21 @@ async def restart_gateway(): @app.post("/api/hermes/update") async def update_hermes(): """Kick off ``hermes update`` in the background.""" + if _dashboard_hosted_agent_mode(): + message = ( + "Hermes updates are managed by the hosted agent service for this " + "dashboard. The built-in local updater is disabled here." + ) + _record_completed_action("hermes-update", message, exit_code=1) + return { + "ok": False, + "pid": None, + "name": "hermes-update", + "error": "hosted_update_managed", + "message": message, + "update_command": "managed by hosted agent service", + } + install_method = detect_install_method(PROJECT_ROOT) if install_method == "docker": message = format_docker_update_message() @@ -2264,6 +2291,17 @@ async def check_hermes_update(force: bool = False): desktop's remote update overlay renders this as "what's changed". Additive: existing consumers ignore it. """ + if _dashboard_hosted_agent_mode(): + return { + "install_method": "hosted", + "current_version": __version__, + "behind": None, + "update_available": False, + "can_apply": False, + "update_command": "managed by hosted agent service", + "message": "Hermes updates are managed by the hosted agent service.", + } + install_method = detect_install_method(PROJECT_ROOT) update_command = recommended_update_command_for_method(install_method) diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 60ee50728a1..933615e8974 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -772,6 +772,25 @@ class TestUpdateCheckEndpoint: assert body["message"] assert body["behind"] is None + def test_hosted_dashboard_is_not_applyable(self, monkeypatch): + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws, "_dashboard_hosted_agent_mode", lambda: True) + monkeypatch.setattr( + ws, + "detect_install_method", + lambda *a, **k: pytest.fail( + "hosted update check should not probe install method" + ), + ) + + body = self.client.get("/api/hermes/update/check").json() + assert body["install_method"] == "hosted" + assert body["can_apply"] is False + assert body["update_available"] is False + assert body["behind"] is None + assert "hosted agent service" in body["message"] + def test_check_failure_is_soft(self, monkeypatch): import hermes_cli.web_server as ws import hermes_cli.banner as banner diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 87047432721..1ad0277dbe8 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -245,6 +245,16 @@ class TestWebServerEndpoints: assert "version" in data assert "hermes_home" in data assert "active_sessions" in data + assert data["can_update_hermes"] is True + + def test_get_status_hides_update_capability_in_hosted_mode(self, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr(web_server, "_dashboard_hosted_agent_mode", lambda: True) + + resp = self.client.get("/api/status") + assert resp.status_code == 200 + assert resp.json()["can_update_hermes"] is False # ── GET /api/media (remote image display) ─────────────────────────── @@ -912,6 +922,48 @@ class TestWebServerEndpoints: assert status_data["pid"] is None assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"]) + def test_update_hermes_returns_hosted_guidance_without_spawning(self, monkeypatch): + import hermes_cli.web_server as web_server + + spawned = False + detected = False + + def fail_spawn(*_args, **_kwargs): + nonlocal spawned + spawned = True + raise AssertionError("hosted update guard should not spawn hermes update") + + def fail_detect(*_args, **_kwargs): + nonlocal detected + detected = True + raise AssertionError("hosted update guard should not detect install method") + + monkeypatch.setattr(web_server, "_dashboard_hosted_agent_mode", lambda: True) + monkeypatch.setattr(web_server, "detect_install_method", fail_detect) + monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn) + web_server._ACTION_PROCS.pop("hermes-update", None) + web_server._ACTION_RESULTS.pop("hermes-update", None) + + resp = self.client.post("/api/hermes/update") + + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is False + assert data["name"] == "hermes-update" + assert data["pid"] is None + assert data["error"] == "hosted_update_managed" + assert "hosted agent service" in data["message"] + assert spawned is False + assert detected is False + + status = self.client.get("/api/actions/hermes-update/status") + assert status.status_code == 200 + status_data = status.json() + assert status_data["running"] is False + assert status_data["exit_code"] == 1 + assert status_data["pid"] is None + assert any("hosted agent service" in line for line in status_data["lines"]) + def test_update_hermes_spawns_on_non_docker_install(self, monkeypatch): import hermes_cli.web_server as web_server diff --git a/web/src/App.tsx b/web/src/App.tsx index d3c976358d5..8bf6f4ee96e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,8 @@ import { useRef, useState, type ComponentType, + type FocusEvent, + type MouseEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; @@ -812,26 +814,33 @@ function SidebarNavLink({ t, }: SidebarNavLinkProps) { const { path, label, labelKey, icon: Icon } = item; - const liRef = useRef(null); const [hovered, setHovered] = useState(false); + const [tooltipAnchor, setTooltipAnchor] = useState(null); const navLabel = labelKey ? ((t.app.nav as Record)[labelKey] ?? label) : label; + const showTooltip = (event: MouseEvent | FocusEvent) => { + setHovered(true); + setTooltipAnchor(event.currentTarget); + }; + const hideTooltip = () => { + setHovered(false); + setTooltipAnchor(null); + }; return (
  • setHovered(true) : undefined} - onMouseLeave={collapsed ? () => setHovered(false) : undefined} + onMouseEnter={collapsed ? showTooltip : undefined} + onMouseLeave={collapsed ? hideTooltip : undefined} > setHovered(true) : undefined} - onBlur={collapsed ? () => setHovered(false) : undefined} + onFocus={collapsed ? showTooltip : undefined} + onBlur={collapsed ? hideTooltip : undefined} className={({ isActive }) => cn( "group/nav relative flex items-center gap-3", @@ -877,8 +886,8 @@ function SidebarNavLink({ )} - {collapsed && hovered && liRef.current && ( - + {collapsed && hovered && tooltipAnchor && ( + )}
  • ); @@ -894,6 +903,7 @@ function SidebarSystemActions({ const navigate = useNavigate(); const { activeAction, isBusy, isRunning, pendingAction, runAction } = useSystemActions(); + const canUpdateHermes = status?.can_update_hermes === true; const items: SystemActionItem[] = [ { @@ -903,14 +913,16 @@ function SidebarSystemActions({ runningLabel: t.status.restartingGateway, spin: true, }, - { + ]; + if (canUpdateHermes) { + items.push({ action: "update", icon: Download, label: t.status.updateHermes, runningLabel: t.status.updatingHermes, spin: false, - }, - ]; + }); + } const handleClick = (action: SystemAction) => { if (isBusy) return; @@ -971,24 +983,31 @@ function SystemActionButton({ tooltipWarmRef, }: SystemActionButtonProps) { const { icon: Icon, label, runningLabel, spin } = item; - const liRef = useRef(null); const [hovered, setHovered] = useState(false); + const [tooltipAnchor, setTooltipAnchor] = useState(null); const busy = isPending || isActionRunning; const displayLabel = isActionRunning ? runningLabel : label; + const showTooltip = (event: MouseEvent | FocusEvent) => { + setHovered(true); + setTooltipAnchor(event.currentTarget); + }; + const hideTooltip = () => { + setHovered(false); + setTooltipAnchor(null); + }; return (
  • setHovered(true) : undefined} - onMouseLeave={collapsed ? () => setHovered(false) : undefined} + onMouseEnter={collapsed ? showTooltip : undefined} + onMouseLeave={collapsed ? hideTooltip : undefined} > - {collapsed && hovered && liRef.current && ( - + {collapsed && hovered && tooltipAnchor && ( + )}
  • ); @@ -1049,18 +1068,25 @@ function SidebarIconWithTooltip({ label, tooltipWarmRef, }: SidebarIconWithTooltipProps) { - const ref = useRef(null); const [hovered, setHovered] = useState(false); + const [tooltipAnchor, setTooltipAnchor] = useState(null); + const showTooltip = (event: MouseEvent) => { + setHovered(true); + setTooltipAnchor(event.currentTarget); + }; + const hideTooltip = () => { + setHovered(false); + setTooltipAnchor(null); + }; return (
    setHovered(true) : undefined} - onMouseLeave={collapsed ? () => setHovered(false) : undefined} + onMouseEnter={collapsed ? showTooltip : undefined} + onMouseLeave={collapsed ? hideTooltip : undefined} > {children} @@ -1071,8 +1097,8 @@ function SidebarIconWithTooltip({ /> )} - {collapsed && hovered && ref.current && ( - + {collapsed && hovered && tooltipAnchor && ( + )}
    ); @@ -1080,8 +1106,8 @@ function SidebarIconWithTooltip({ function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) { const { t } = useI18n(); - const ref = useRef(null); const [hovered, setHovered] = useState(false); + const [tooltipAnchor, setTooltipAnchor] = useState(null); const toneToColor: Record = { "text-success": "bg-success", @@ -1101,10 +1127,17 @@ function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) { color = toneToColor[gw.tone] ?? "bg-muted-foreground"; label = `${t.status.gateway} ${gw.label}`; } + const showTooltip = (event: MouseEvent | FocusEvent) => { + setHovered(true); + setTooltipAnchor(event.currentTarget); + }; + const hideTooltip = () => { + setHovered(false); + setTooltipAnchor(null); + }; return (
    setHovered(true) : undefined} - onMouseLeave={collapsed ? () => setHovered(false) : undefined} - onFocus={collapsed ? () => setHovered(true) : undefined} - onBlur={collapsed ? () => setHovered(false) : undefined} + onMouseEnter={collapsed ? showTooltip : undefined} + onMouseLeave={collapsed ? hideTooltip : undefined} + onFocus={collapsed ? showTooltip : undefined} + onBlur={collapsed ? hideTooltip : undefined} > - {hovered && ref.current && ( - + {hovered && tooltipAnchor && ( + )}
    ); @@ -1133,11 +1166,16 @@ function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) { const rect = anchor.getBoundingClientRect(); const sidebar = document.getElementById("app-sidebar"); const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right; - - const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false; + const [isWarm, setIsWarm] = useState(false); useEffect(() => { - if (warmRef) warmRef.current = Date.now(); + if (!warmRef) { + setIsWarm(false); + return; + } + const now = Date.now(); + setIsWarm(now - warmRef.current < 300); + warmRef.current = now; return () => { if (warmRef) warmRef.current = Date.now(); }; diff --git a/web/src/contexts/SystemActions.tsx b/web/src/contexts/SystemActions.tsx index 976bf4c32aa..2dd05232c05 100644 --- a/web/src/contexts/SystemActions.tsx +++ b/web/src/contexts/SystemActions.tsx @@ -74,19 +74,17 @@ export function SystemActionsProvider({ setActiveAction(action); } else { const resp = await api.updateHermes(); - // In a Docker install the image is immutable, so `hermes update` - // can't apply — the endpoint returns 200 with a structured - // {ok:false, error:"docker_update_unsupported", message, update_command} - // envelope instead of spawning the action (see #34347 / #36263). - // Surface that guidance to the user rather than starting the poll, - // which would otherwise report a generic "failed (exit 1)". - if (!resp.ok && resp.error === "docker_update_unsupported") { + // Some installs cannot apply updates from inside the dashboard. The + // endpoint returns a structured {ok:false, message, update_command} + // envelope instead of spawning the action; surface that guidance + // rather than polling a synthetic failed action. + if (!resp.ok) { const cmd = resp.update_command ? ` ${resp.update_command}` : ""; setToast({ type: "success", message: (resp.message ?? - "Updates don't apply inside Docker — re-pull the image instead.") + + "Updates don't apply from this dashboard.") + cmd, }); return; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fab64b64c84..2a49d5a9f7e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1570,6 +1570,9 @@ export interface StatusResponse { * Empty in loopback mode; empty + ``auth_required=true`` is a * fail-closed state (the dashboard will refuse to bind). */ auth_providers?: string[]; + /** False when the dashboard is running in a hosted/managed layout where + * updates are handled by the outer launcher instead of ``hermes update``. */ + can_update_hermes?: boolean; config_path: string; config_version: number; env_path: string; diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index 6197b456e5d..f22bb553218 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -386,6 +386,7 @@ export default function SystemPage() { // ── Update check / apply ─────────────────────────────────────────── const checkForUpdate = useCallback( async (force = false) => { + if (status?.can_update_hermes === false) return; setCheckingUpdate(true); try { const info = await api.checkHermesUpdate(force); @@ -410,20 +411,27 @@ export default function SystemPage() { setCheckingUpdate(false); } }, - [showToast], + [showToast, status?.can_update_hermes], ); // Auto-check (cached) runs inside loadAll on mount; this is the // user-triggered forced re-check from the "Check for updates" button. const applyUpdate = async () => { setUpdateConfirmOpen(false); + if (status?.can_update_hermes === false) { + showToast( + "Hermes updates are managed by the hosted agent service.", + "success", + ); + return; + } try { const resp = await api.updateHermes(); - if (!resp.ok && resp.error === "docker_update_unsupported") { + if (!resp.ok) { showToast( resp.message ?? - "Updates don't apply inside Docker — re-pull the image instead.", - "error", + "Updates don't apply from this dashboard.", + "success", ); return; } @@ -503,6 +511,7 @@ export default function SystemPage() { } const gatewayRunning = status?.gateway_running; + const canUpdateHermes = status?.can_update_hermes !== false; const validEvents = hooks?.valid_events?.length ? hooks.valid_events : HOOK_EVENTS_FALLBACK; @@ -512,7 +521,7 @@ export default function SystemPage() { setUpdateConfirmOpen(false)} onConfirm={() => void applyUpdate()} title="Update Hermes?" @@ -691,7 +700,8 @@ export default function SystemPage() {
    Hermes
    v{stats?.hermes_version} - {updateInfo && + {canUpdateHermes && + updateInfo && (updateInfo.update_available ? ( {updateInfo.behind && updateInfo.behind > 0 @@ -751,45 +761,47 @@ export default function SystemPage() { CPU / memory / disk metrics.

    )} -
    - - {updateInfo?.update_available && updateInfo.can_apply && ( + {canUpdateHermes && ( +
    - )} - {updateInfo && - !updateInfo.can_apply && - updateInfo.update_available && ( + {updateInfo?.update_available && updateInfo.can_apply && ( + + )} + {updateInfo && + !updateInfo.can_apply && + updateInfo.update_available && ( + + Update with{" "} + {updateInfo.update_command} + + )} + {updateInfo?.message && !updateInfo.update_available && ( - Update with{" "} - {updateInfo.update_command} + {updateInfo.message} )} - {updateInfo?.message && !updateInfo.update_available && ( - - {updateInfo.message} - - )} -
    +
    + )}