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:
@@ -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.
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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))}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user