diff --git a/gateway/host_rendezvous.py b/gateway/host_rendezvous.py index 86963033b3..543961d2c7 100644 --- a/gateway/host_rendezvous.py +++ b/gateway/host_rendezvous.py @@ -61,7 +61,13 @@ PROBE_TIMEOUT_S = 2.0 ROLE_GATEWAY = "gateway" ROLE_SERVE = "serve" -_ROLES = (ROLE_GATEWAY, ROLE_SERVE) +#: A Desktop-owned pool child (loopback, random port, per-profile lifecycle). It is NOT a host +#: owner — the attach/refuse ladder reads ``ROLE_SERVE`` only, so a supervised public dashboard +#: never stands down behind it (#119824) — but ``hermes plugins install`` from a terminal still +#: has to reach the backend hosting the open chats (#119644), and this record + 0600 token is +#: how it dials one on a Desktop-only box. +ROLE_DESKTOP_SERVE = "desktop-serve" +_ROLES = (ROLE_GATEWAY, ROLE_SERVE, ROLE_DESKTOP_SERVE) # Open lock handles, keyed by (role, resolved lock path): the OS releases the flock when this # process dies, which is what makes a crashed owner's host lock re-acquirable without a reaper. diff --git a/hermes_cli/_startup_fast.py b/hermes_cli/_startup_fast.py index 74491fbf00..d4fe0818d5 100644 --- a/hermes_cli/_startup_fast.py +++ b/hermes_cli/_startup_fast.py @@ -16,7 +16,7 @@ __all__ = [ "is_termux_fast_version_argv", "is_global_fast_version_argv", "is_container_startup_environment", "active_profile_may_override_home", "container_mode_may_be_active", "read_openai_version", "read_install_method", - "print_fast_version_info", "try_fast_version", + "print_fast_version_info", "try_fast_version", "is_desktop_ssh_backend_argv", ] @@ -90,6 +90,17 @@ def is_termux_fast_version_argv(argv: list[str]) -> bool: is_global_fast_version_argv = is_termux_fast_version_argv +def is_desktop_ssh_backend_argv(argv: list[str]) -> bool: + """Is ``argv`` the Desktop client's SSH backend spawn (``serve --ssh-session-token-file``)? + + That child has a fixed identity: Desktop names the remote profile explicitly (or none for + the root home) and hands its session token through a 0600 FILE, never the + ``HERMES_DASHBOARD_SESSION_TOKEN`` env var the local pool spawn uses. Every reader of + "is this process Desktop's backend" needs both shapes; this is the argv half. + """ + return "--ssh-session-token-file" in argv + + def is_container_startup_environment() -> bool: """True when we're already INSIDE a container (fast path is then safe).""" if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"): diff --git a/hermes_cli/dashboard_procs.py b/hermes_cli/dashboard_procs.py index 620c1586bd..40e65fe724 100644 --- a/hermes_cli/dashboard_procs.py +++ b/hermes_cli/dashboard_procs.py @@ -10,6 +10,8 @@ import subprocess import sys from pathlib import Path +from hermes_cli._startup_fast import is_desktop_ssh_backend_argv + # Cmdline substrings identifying the long-lived server (``serve`` = the headless name Desktop # spawns; reaped on update for the same reason). _DASHBOARD_PATTERNS = tuple( @@ -179,7 +181,7 @@ def _hermes_home_for_pid(pid: int) -> str | None: default_home = Path(env.get("HOME") or _pid_passwd_home(pid) or Path.home()) / ".hermes" root = profile_root_for_env_home(env_home, default_home) fixed_identity = any(env.get(k) for k in ("HERMES_SUPERVISED_CHILD", "HERMES_S6_SUPERVISED_CHILD", - "HERMES_GATEWAY_EXTERNAL_SUPERVISOR")) or "--ssh-session-token-file" in argv + "HERMES_GATEWAY_EXTERNAL_SUPERVISOR")) or is_desktop_ssh_backend_argv(argv) if profile is None and not fixed_identity: profile = get_active_profile(root) canon = normalize_profile_name(profile) if profile else "default" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d9c90d426e..be58df9a90 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -566,17 +566,6 @@ def _under_gateway_supervisor(argv: list) -> bool: ).strip().lower() in {"1", "true", "yes", "on"} -def _desktop_ssh_backend(argv: list) -> bool: - """A Desktop-owned ``serve --ssh-session-token-file`` child has a fixed identity too. - - The Desktop client names the remote profile explicitly (``--profile ``, or none for - the root home). Following the remote host's sticky ``active_profile`` instead silently - re-homes the backend into a profile the UI never asked for, so Settings read one - ``config.yaml`` and the user edits another (KC's "nothing sticks over SSH"). - """ - return "--ssh-session-token-file" in argv - - def _s6_supervised_gateway_run(argv: list) -> bool: """A bare ``gateway run`` inside the s6 image names the ``gateway-default`` slot too. @@ -614,7 +603,8 @@ def _apply_profile_override() -> None: if profile_name is None and hermes_home_env and os.environ.get("HERMES_UPDATE_POST_SWAP") == "1": return - if (profile_name is None and not _under_gateway_supervisor(argv) and not _desktop_ssh_backend(argv) + if (profile_name is None and not _under_gateway_supervisor(argv) + and not _startup_fast.is_desktop_ssh_backend_argv(argv) and not _s6_supervised_gateway_run(argv)): try: from hermes_constants import get_default_hermes_root diff --git a/hermes_cli/plugins_activation.py b/hermes_cli/plugins_activation.py index 869827e353..19652c7bd4 100644 --- a/hermes_cli/plugins_activation.py +++ b/hermes_cli/plugins_activation.py @@ -170,17 +170,27 @@ def _go_live(name: str) -> Optional[Dict[str, Any]]: return activation +def _serve_backend_record(): + """The host-owner record, else the Desktop child's (a Desktop-only box has no host owner).""" + from gateway import host_rendezvous as hr + for role in (hr.ROLE_SERVE, hr.ROLE_DESKTOP_SERVE): + record = hr.read_record(role) + if record is not None and record.port and hr.record_token_is_consistent(record): + return record + return None + + def notify_serve_backend(name: str, home: Path) -> Optional[Dict[str, Any]]: - """Ask the running Desktop / dashboard backend (``hermes serve``, found through its host record) to + """Ask the running dashboard / Desktop backend (``hermes serve``, found through its host record) to run :func:`load_and_go_live` for ``name`` in ``home``. None when no backend answers. Never raises.""" try: import json import urllib.request from gateway import host_rendezvous as hr - record = hr.read_record(hr.ROLE_SERVE) - if record is None or not record.port or not hr.record_token_is_consistent(record): + record = _serve_backend_record() + if record is None: return None - token = hr.read_token(hr.ROLE_SERVE) + token = hr.read_token(record.role) request = urllib.request.Request( f"http://{hr.dial_host(record)}:{record.port}/api/dashboard/agent-plugins/activate", data=json.dumps({"name": name, "home": str(home)}).encode("utf-8"), method="POST", diff --git a/hermes_cli/process_identity.py b/hermes_cli/process_identity.py index 70bd6fe59b..264db31592 100644 --- a/hermes_cli/process_identity.py +++ b/hermes_cli/process_identity.py @@ -14,11 +14,12 @@ import json import logging import os import platform +import sys import threading import time from dataclasses import asdict, dataclass from pathlib import Path -from typing import Optional +from typing import Optional, Sequence from utils import atomic_json_write @@ -218,18 +219,23 @@ def register_self(purpose: str, *, project_root: Optional[Path] = None, detail: return _append_entry(entry) -def is_desktop_owned_backend() -> bool: +def is_desktop_owned_backend(argv: Optional[Sequence[str]] = None) -> bool: """Whether this process is the backend Desktop spawned and owns. ``HERMES_DESKTOP=1`` is inherited by every shell and agent child the app launches, so the - flag alone is not ownership proof (same class as #116107). Desktop mints a per-spawn - ``HERMES_DASHBOARD_SESSION_TOKEN`` only for its backend; the terminal pane never receives - it and the terminal tool's env policy strips it from agent children. + flag alone is not ownership proof (same class as #116107). Desktop hands its backend a + per-spawn credential the terminal pane never receives (and the terminal tool's env policy + strips from agent children): the local pool spawn mints ``HERMES_DASHBOARD_SESSION_TOKEN``, + the SSH spawn passes a 0600 token FILE on argv and deliberately sets no token env var. + ``argv`` defaults to this process's own. """ - return ( - os.environ.get("HERMES_DESKTOP") == "1" - and bool(os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN")) - ) + if os.environ.get("HERMES_DESKTOP") != "1": + return False + if os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN"): + return True + from hermes_cli._startup_fast import is_desktop_ssh_backend_argv + + return is_desktop_ssh_backend_argv(list(sys.argv[1:] if argv is None else argv)) def _desktop_spawner_identity() -> tuple[Optional[int], Optional[float]]: diff --git a/hermes_cli/web_routers/status.py b/hermes_cli/web_routers/status.py index 0b1b75cd0f..cbe8399805 100644 --- a/hermes_cli/web_routers/status.py +++ b/hermes_cli/web_routers/status.py @@ -130,9 +130,11 @@ async def get_host_identity(request: Request): headless ``serve``, so a `hermes dashboard` user is never routed to a backend with no UI. """ _require_token(request) - # ``role`` is the host ROLE this process owns (gateway/host_rendezvous.ROLE_SERVE), not the - # launch mode: `hermes serve` and `hermes dashboard` are one host role that differ in SPA. - return {"ok": True, "protocolVersion": 1, "pid": os.getpid(), "role": "serve", + # ``role`` is the host ROLE this process published (gateway/host_rendezvous.ROLE_SERVE, or + # ROLE_DESKTOP_SERVE for a Desktop-owned child), not the launch mode: `hermes serve` and + # `hermes dashboard` are one host role that differ in SPA. + return {"ok": True, "protocolVersion": 1, "pid": os.getpid(), + "role": getattr(app.state, "host_role", None) or "serve", "servesSpa": bool(getattr(app.state, "serves_spa", False))} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8ae2774476..c8832caa3a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -23,6 +23,7 @@ import time import urllib.parse from hermes_cli.install_identity import get_install_id as _shared_get_install_id +from hermes_cli.process_identity import is_desktop_owned_backend from hermes_cli.pty_session import run_reaper from pathlib import Path from typing import Any, Dict, Optional, Tuple @@ -201,12 +202,13 @@ async def _lifespan(app: "FastAPI"): ) hosted_room_start_thread.start() - # Desktop-spawned backends (HERMES_DESKTOP=1) fire cron jobs themselves, - # since the app has no gateway running the scheduler. Server `hermes - # dashboard` is unaffected — it relies on its own gateway. + # Desktop-spawned backends fire cron jobs themselves, since the app has no + # gateway running the scheduler. Server `hermes dashboard` is unaffected — + # it relies on its own gateway. cron_stop: "threading.Event | None" = None cron_thread: "threading.Thread | None" = None - if os.getenv("HERMES_DESKTOP") == "1": + desktop_owned = is_desktop_owned_backend() + if desktop_owned: # Reap an orphaned gateway from an abnormal previous exit (reparented to # launchd, still holding the platform WebSocket) before forking a fresh # one that would race the same credential (#77276). Runs @@ -277,7 +279,7 @@ async def _lifespan(app: "FastAPI"): shutdown_local_runtime() except Exception: # noqa: BLE001 pass - if os.getenv("HERMES_DESKTOP") == "1": + if desktop_owned: _terminate_desktop_managed_gateway() eager_reconcile_thread.join() @@ -1212,35 +1214,41 @@ def _best_effort(what: str, fn) -> None: def _publish_host_rendezvous(host: str, port: int) -> None: - """Publish the machine-level serve owner unless this is a Desktop-owned pool child.""" - # Desktop-spawned backends (flag + per-spawn token; the bare flag is inherited by every - # Desktop shell) are loopback, random-port and per-profile. Recording one as the host owner + """Publish this backend's host record: ``ROLE_SERVE`` for the machine-level owner, + ``ROLE_DESKTOP_SERVE`` for a Desktop-owned child.""" + # Desktop-spawned backends (flag + per-spawn credential; the bare flag is inherited by every + # Desktop shell) are loopback, random-port and per-profile. Recording one as the HOST owner # made a later independently supervised `dashboard --host 0.0.0.0 --port N` refuse behind - # the private child on every restart (#119824). Desktop discovers its own backends through - # spawn-ledger.json, never through this record. - from hermes_cli.process_identity import is_desktop_owned_backend - - if is_desktop_owned_backend(): - return - + # the private child on every restart (#119824): the attach/refuse ladder reads ROLE_SERVE + # only. They still publish under their own role so `hermes plugins install` from a terminal + # can reach the backend hosting the open chats on a Desktop-only box (#119644). from gateway import host_rendezvous as hr - outcome, error = hr.claim_host_lock(hr.ROLE_SERVE) + desktop_child = is_desktop_owned_backend() + role = hr.ROLE_DESKTOP_SERVE if desktop_child else hr.ROLE_SERVE + + outcome, error = hr.claim_host_lock(role) if outcome is hr.HostLockOutcome.COULD_NOT_OPEN: _log.warning( "Host backend lock could not be opened (%s); this backend is not discoverable. " "This is NOT another backend holding it.", error) return if outcome is hr.HostLockOutcome.HELD_BY_OTHER: - owner = hr.read_record(hr.ROLE_SERVE) + owner = hr.read_record(role) + if desktop_child: + # A second pool child is Desktop's own topology, not a conflict. + _log.debug("another Desktop backend holds the %s record (%s)", role, + hr.describe(owner) if owner else "owner unknown") + return _log.warning( "Another backend already owns this host (%s); this one bound anyway " "(observe-only). Multiplex-only expects exactly one backend per host.", hr.describe(owner) if owner else "owner unknown", ) return + app.state.host_role = role hr.publish_record( - hr.ROLE_SERVE, + role, host=host, port=port, profiles=hr.served_profiles(), @@ -1249,7 +1257,7 @@ def _publish_host_rendezvous(host: str, port: int) -> None: token=_SESSION_TOKEN, ) # SIGTERM included: it is the normal stop, and it does not run atexit here. - hr.cleanup_on_exit(hr.ROLE_SERVE) + hr.cleanup_on_exit(role) def _on_server_started( @@ -1282,7 +1290,7 @@ def _on_server_started( reap_orphaned_mcp_helpers() - if os.getenv("HERMES_DESKTOP") == "1": + if is_desktop_owned_backend(): _best_effort("orphan desktop-local serve reap", _reap_desktop_serves) # Same sweep for stdio MCP helpers (#61514): positive identity only (spawn # ledger + spawner provably dead); anything alive or unprovable is untouched. diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 8aead9eac0..c111e47e6c 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -1083,6 +1083,7 @@ def test_desktop_lifespan_terminates_managed_gateway_restart(monkeypatch): calls.append("terminate") monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-spawn-token") monkeypatch.setattr(ws, "_warm_gateway_module", lambda: None) monkeypatch.setattr(ws, "_start_desktop_cron_ticker", lambda *_args: None) monkeypatch.setitem(_web_server_gateway._ACTION_PROCS, "gateway-restart", _FakeRunningProc()) diff --git a/tests/hermes_cli/test_process_identity.py b/tests/hermes_cli/test_process_identity.py index df9fc548e1..2ac50a0f01 100644 --- a/tests/hermes_cli/test_process_identity.py +++ b/tests/hermes_cli/test_process_identity.py @@ -268,3 +268,19 @@ def test_updater_ledger_rung_never_raises(): with patch.object(pi, "ledger_entries", side_effect=RuntimeError("boom")): assert cli_main._ledger_reapable_backend_pids(_holders(200)) == [] + + +def test_desktop_ssh_backend_spawn_shape_is_desktop_owned(monkeypatch): + """Desktop's SSH spawn is ``env HERMES_DESKTOP=1 hermes serve --isolated ... --ssh-session-token-file F`` + with NO token env var (its tests assert the var name never appears on the wire). Missing that + shape made the SSH child claim ROLE_SERVE on the remote host (the #119824 shape there).""" + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False) + ssh_argv = ["serve", "--isolated", "--host", "127.0.0.1", "--port", "0", + "--ssh-session-token-file", "/home/u/.hermes/desktop-ssh/abc.token"] + + assert pi.is_desktop_owned_backend(ssh_argv) is True + monkeypatch.setattr(sys, "argv", ["hermes", *ssh_argv]) + assert pi.is_desktop_owned_backend() is True + # The bare inherited flag (a Desktop terminal pane running `hermes serve`) is still not ownership. + assert pi.is_desktop_owned_backend(["serve", "--host", "127.0.0.1", "--port", "0"]) is False diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 43c9abb752..790590e031 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -3174,19 +3174,49 @@ class TestDesktopLoopbackAuthExemption: class TestDesktopHostRendezvousIsolation: """Desktop pool children have a private lifecycle, not a host ownership role.""" - def test_desktop_backend_does_not_claim_the_host_serve_record(self, monkeypatch): - """A Desktop child must not block a separately supervised public dashboard.""" + def test_desktop_backend_does_not_claim_the_host_serve_record(self, monkeypatch, tmp_path): + """A Desktop child must not block a separately supervised public dashboard, yet a + terminal `hermes plugins install` on a Desktop-only box must still find it (#119644): + it publishes under its OWN role, which the attach ladder never reads.""" + import io + import urllib.request from gateway import host_rendezvous as hr import hermes_cli.web_server as web_server + from hermes_cli.main_dashboard import _host_backend_attachment + from hermes_cli.plugins_activation import notify_serve_backend + monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks")) monkeypatch.setenv("HERMES_DESKTOP", "1") monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-spawn-token") - claimed = [] - monkeypatch.setattr(hr, "claim_host_lock", lambda role: claimed.append(role)) + monkeypatch.setattr(web_server, "_SESSION_TOKEN", "desktop-spawn-token") + monkeypatch.setattr(hr, "cleanup_on_exit", lambda role: None) + dialed = [] - web_server._publish_host_rendezvous("127.0.0.1", 9231) + class _Reply(io.BytesIO): + def __enter__(self): + return self - assert claimed == [] + def __exit__(self, *exc): + return False + + def _fake_urlopen(request, timeout=None): + dialed.append((request.full_url, request.get_header("X-hermes-session-token"))) + return _Reply(b'{"ok": true}') + + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen) + try: + web_server._publish_host_rendezvous("127.0.0.1", 9231) + + # Not a host owner: the supervised public dashboard's attach ladder sees nobody. + assert hr.read_record(hr.ROLE_SERVE) is None + assert _host_backend_attachment() is None + # ...but a terminal `hermes plugins install` still lights up its open chats. + assert notify_serve_backend("demo", tmp_path) == {"ok": True} + assert dialed == [("http://127.0.0.1:9231/api/dashboard/agent-plugins/activate", + "desktop-spawn-token")] + finally: + hr.clear_record(hr.ROLE_DESKTOP_SERVE) + hr.release_host_lock(hr.ROLE_DESKTOP_SERVE) def test_standalone_backend_still_claims_the_host_serve_record(self, monkeypatch): """The Desktop exclusion must not alter standalone dashboard discovery — including a @@ -4877,9 +4907,10 @@ class TestDesktopCronTicker: called = threading.Event() monkeypatch.setattr(sched, "tick", lambda *a, **k: called.set()) monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-spawn-token") with self._client(): - assert called.wait(3.0), "expected cron tick under HERMES_DESKTOP=1" + assert called.wait(3.0), "expected cron tick under a Desktop-owned backend" class TestServeIndexMissingIndex: