fix(dashboard): Desktop children publish their own host role; SSH spawn shape is Desktop-owned

Review follow-up on the host-rendezvous isolation:

* Excluding Desktop-owned children from ROLE_SERVE broke #119644's terminal
  path: `hermes plugins install` found "the running Desktop backend" only via
  read_record(ROLE_SERVE), so on a Desktop-only box open chats no longer lit
  up. A Desktop child now publishes ROLE_DESKTOP_SERVE (own lock, record and
  0600 token). The attach/refuse ladder (_host_backend_attachment) still reads
  ROLE_SERVE only, so a supervised public dashboard is never blocked by it;
  notify_serve_backend prefers the host owner and falls back to the Desktop
  record. /api/host/identity reports the role actually published.

* is_desktop_owned_backend() missed Desktop's SSH spawn — `env HERMES_DESKTOP=1
  hermes serve --isolated --ssh-session-token-file F` carries NO token env var
  (remote-lifecycle tests assert the var name never appears) — so the SSH child
  still claimed ROLE_SERVE on the remote host and its MCP discovery flipped to
  deferred. The predicate now accepts the token-FILE argv shape; the argv half
  lives in _startup_fast.is_desktop_ssh_backend_argv (stdlib-only, importable
  before main.py's import wall) and replaces the two duplicate substring checks
  in main.py and dashboard_procs.py.

* web_server.py's remaining three bare HERMES_DESKTOP reads (cron ticker,
  managed-gateway teardown, orphan serve reap) route through the predicate;
  the tests that modelled a Desktop child with the bare flag set the spawn
  token, as a real pool child does.
This commit is contained in:
teknium1
2026-09-23 06:23:35 -07:00
committed by Teknium
parent 9ca3b54e91
commit 746f7ea21b
11 changed files with 141 additions and 58 deletions

View File

@@ -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.

View File

@@ -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"):

View File

@@ -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"

View File

@@ -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 <name>``, 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

View File

@@ -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",

View File

@@ -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]]:

View File

@@ -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))}

View File

@@ -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.

View File

@@ -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())

View File

@@ -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

View File

@@ -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: