From 025d4155acea88af6b0b03d8bf3b0a815fda22da Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 23 Sep 2026 11:00:15 -0400 Subject: [PATCH] fix(profile): preserve scoped dashboard and launcher behavior --- agent/web_search_provider.py | 23 +++++++++---- hermes_cli/stderr_timestamp.py | 11 ++++--- hermes_cli/web_routers/dashboard_ui.py | 4 +-- hermes_cli/web_server_dashboard.py | 33 ++++++++----------- .../test_memory_provider_migration.py | 4 +-- web/src/components/AutomationBlueprints.tsx | 6 ++-- web/src/lib/api.ts | 12 ++++--- web/src/pages/CronPage.tsx | 24 +++++++++----- 8 files changed, 69 insertions(+), 48 deletions(-) diff --git a/agent/web_search_provider.py b/agent/web_search_provider.py index 947b95cc04..5cb07151fb 100644 --- a/agent/web_search_provider.py +++ b/agent/web_search_provider.py @@ -35,19 +35,30 @@ def get_provider_env(name: str) -> str: from hermes_cli.config import get_env_value val = get_env_value(name) - except Exception: # noqa: BLE001 — config layer optional here + except Exception as exc: # noqa: BLE001 — config layer optional here + try: + from agent.secret_scope import UnscopedSecretError + except ImportError: + UnscopedSecretError = () # type: ignore[assignment,misc] + if isinstance(exc, UnscopedSecretError): + raise val = None - if val is None and not _secret_scope_bound(): + scope_bound, multiplex_active = _secret_scope_state() + if val is None and multiplex_active and not scope_bound: + from agent.secret_scope import UnscopedSecretError + + raise UnscopedSecretError(name, f"get_provider_env({name!r}) called with no active profile scope") + if val is None and not scope_bound: val = os.getenv(name, "") return (val or "").strip() -def _secret_scope_bound() -> bool: +def _secret_scope_state() -> tuple[bool, bool]: try: - from agent.secret_scope import current_secret_scope + from agent.secret_scope import current_secret_scope, is_multiplex_active except Exception: # noqa: BLE001 — stripped install without the scope module - return False - return current_secret_scope() is not None + return False, False + return current_secret_scope() is not None, is_multiplex_active() class WebSearchProvider(ProviderBase): diff --git a/hermes_cli/stderr_timestamp.py b/hermes_cli/stderr_timestamp.py index 8d0c215491..6bb16e6c89 100644 --- a/hermes_cli/stderr_timestamp.py +++ b/hermes_cli/stderr_timestamp.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import BinaryIO, Sequence, TextIO EXTERNAL_SUPERVISOR_FLAG = "--external-supervisor" +_LAUNCHD_LABEL_ENV = "HERMES_LAUNCHD_LABEL" # gateway.restart.GATEWAY_FATAL_CONFIG_EXIT_CODE. This wrapper is a launcher boot # file: it runs from a source slice and stays stdlib-only. _GATEWAY_FATAL_CONFIG_EXIT_CODE = 78 @@ -98,10 +99,12 @@ def _child_launchd_label_env(environ: Mapping[str, str] | None = None) -> dict[s via ``gateway.restart.launchd_job_label``). Only ``ai.hermes.*`` labels are exported; app-coalition labels are meaningless as a job identity. """ - from gateway.restart import LAUNCHD_LABEL_ENV, launchd_job_label - - label = launchd_job_label(os.environ if environ is None else environ) - return {LAUNCHD_LABEL_ENV: label} if label else {} + env = os.environ if environ is None else environ + for variable in ("XPC_SERVICE_NAME", _LAUNCHD_LABEL_ENV): + label = str(env.get(variable, "") or "").strip() + if label.startswith("ai.hermes"): + return {_LAUNCHD_LABEL_ENV: label} + return {} def _prepare_child_command(command: Sequence[str], environ: Mapping[str, str] | None = None) -> list[str]: diff --git a/hermes_cli/web_routers/dashboard_ui.py b/hermes_cli/web_routers/dashboard_ui.py index acf3ecbf79..63de4d7d6b 100644 --- a/hermes_cli/web_routers/dashboard_ui.py +++ b/hermes_cli/web_routers/dashboard_ui.py @@ -154,11 +154,11 @@ async def rescan_dashboard_plugins(): @router.get("/api/dashboard/plugins/hub") -async def get_plugins_hub(request: Request): +async def get_plugins_hub(request: Request, profile: Optional[str] = None): """Unified agent plugins + dashboard extension metadata (session protected).""" _require_token(request) try: - return await asyncio.to_thread(_merged_plugins_hub) + return await config_scoped_to_thread(profile, _merged_plugins_hub) except Exception as exc: _log.warning("plugins/hub failed: %s", exc) raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py index 79b2501a06..7d988a4257 100644 --- a/hermes_cli/web_server_dashboard.py +++ b/hermes_cli/web_server_dashboard.py @@ -584,16 +584,15 @@ def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]: _PLUGINS_HUB_CACHE_TTL_SECONDS = 5.0 -_plugins_hub_cache: Optional[Dict[str, Any]] = None -_plugins_hub_cache_expires_at = 0.0 +_plugins_hub_cache: Dict[str, Dict[str, Any]] = {} +_plugins_hub_cache_expires_at: Dict[str, float] = {} _plugins_hub_cache_lock = threading.Lock() def _invalidate_plugins_hub_cache() -> None: - global _plugins_hub_cache, _plugins_hub_cache_expires_at with _plugins_hub_cache_lock: - _plugins_hub_cache = None - _plugins_hub_cache_expires_at = 0.0 + _plugins_hub_cache.clear() + _plugins_hub_cache_expires_at.clear() _plugins_hub_probe_inflight: set = set() @@ -668,12 +667,15 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]: from hermes_cli.web_server_memory import _discover_memory_provider_statuses, _normalize_memory_provider_name from hermes_cli.web_server import _get_dashboard_plugins from hermes_cli.config import get_hermes_home, load_config - global _plugins_hub_cache, _plugins_hub_cache_expires_at + from hermes_constants import hermes_home_key + + cache_key = hermes_home_key(get_hermes_home()) now = time.monotonic() if not force_refresh: with _plugins_hub_cache_lock: - if _plugins_hub_cache is not None and now < _plugins_hub_cache_expires_at: - return _plugins_hub_cache + cached = _plugins_hub_cache.get(cache_key) + if cached is not None and now < _plugins_hub_cache_expires_at.get(cache_key, 0.0): + return cached started_at = time.monotonic() from hermes_cli.plugins_cmd import ( @@ -743,16 +745,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]: agent_names = {r["name"] for r in rows} orphan_dashboard = [_strip_dashboard_manifest(p) for p in dashboard_list if str(p["name"]) not in agent_names] - # ``_discover_memory_provider_statuses`` reads provider credentials through ``get_secret`` - # (mem0's ``get_config_schema``/``is_available``). Under multiplexing an unscoped read raises - # ``UnscopedSecretError``; ``probe_availability`` swallows it and the provider renders - # "unavailable" in the dashboard with no user-visible error. This hub is built for the - # dashboard's own (launch) profile, so bind its scope explicitly — a no-op on single-profile - # hosts. - from tui_gateway.launch_profile_policy import launch_profile_scope_if_multiplexed - - with launch_profile_scope_if_multiplexed(): - memory_providers = _discover_memory_provider_statuses() + memory_providers = _discover_memory_provider_statuses() try: context_engines = [{"name": n, "description": desc} for n, desc in _discover_context_engines()] except Exception: @@ -774,8 +767,8 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]: "plugins/hub rebuilt in %.3fs (plugins=%d memory_options=%d)", duration, len(rows), len(memory_providers) ) with _plugins_hub_cache_lock: - _plugins_hub_cache = payload - _plugins_hub_cache_expires_at = time.monotonic() + _PLUGINS_HUB_CACHE_TTL_SECONDS + _plugins_hub_cache[cache_key] = payload + _plugins_hub_cache_expires_at[cache_key] = time.monotonic() + _PLUGINS_HUB_CACHE_TTL_SECONDS return payload diff --git a/tests/hermes_cli/test_memory_provider_migration.py b/tests/hermes_cli/test_memory_provider_migration.py index 9374cd42d8..897d10b91f 100644 --- a/tests/hermes_cli/test_memory_provider_migration.py +++ b/tests/hermes_cli/test_memory_provider_migration.py @@ -61,7 +61,7 @@ def test_provider_unknown_to_catalog_is_reported_not_installed(home, monkeypatch def test_startup_recovery_attempts_each_profile_home(tmp_path, monkeypatch): """One multiplexed process can start agents for two homes missing the same provider.""" from hermes_constants import reset_hermes_home_override, set_hermes_home_override - from tools import lazy_deps + from pm import install as pm_install homes = [tmp_path / "a", tmp_path / "b"] for profile_home in homes: @@ -69,7 +69,7 @@ def test_startup_recovery_attempts_each_profile_home(tmp_path, monkeypatch): (profile_home / "config.yaml").write_text("memory:\n provider: twin\n", encoding="utf-8") monkeypatch.setattr(mig, "_attempted", set()) monkeypatch.setattr(mig, "catalog_source", lambda name: name) - monkeypatch.setattr(lazy_deps, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: True) installed = [] def fake_installer(profile_home): diff --git a/web/src/components/AutomationBlueprints.tsx b/web/src/components/AutomationBlueprints.tsx index 088dd67859..d85bb7a66b 100644 --- a/web/src/components/AutomationBlueprints.tsx +++ b/web/src/components/AutomationBlueprints.tsx @@ -178,8 +178,10 @@ export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprint useEffect(() => { let cancelled = false; + setBlueprints(null); + setLoadError(null); api - .getAutomationBlueprints() + .getAutomationBlueprints(profile) .then((r) => { if (!cancelled) setBlueprints(r.blueprints); }) @@ -189,7 +191,7 @@ export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprint return () => { cancelled = true; }; - }, []); + }, [profile]); if (loadError) { return

Couldn't load blueprints: {loadError}

; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2bec6add99..84e86fd3d0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -685,8 +685,10 @@ export const api = { // Cron jobs getCronJobs: (profile = "all") => fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`), - getCronDeliveryTargets: () => - fetchJSON<{ targets: CronDeliveryTarget[] }>("/api/cron/delivery-targets"), + getCronDeliveryTargets: (profile = "default") => + fetchJSON<{ targets: CronDeliveryTarget[] }>( + `/api/cron/delivery-targets?profile=${encodeURIComponent(profile)}`, + ), createCronJob: (job: CronJobMutation, profile = "default") => fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, { method: "POST", @@ -716,8 +718,10 @@ export const api = { fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }), // Automation Blueprints — parameterized automation blueprints - getAutomationBlueprints: () => - fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"), + getAutomationBlueprints: (profile = "default") => + fetchJSON<{ blueprints: AutomationBlueprint[] }>( + `/api/cron/blueprints?profile=${encodeURIComponent(profile)}`, + ), instantiateAutomationBlueprint: ( body: { blueprint: string; values: Record }, profile = "default", diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index ca57d51970..0d099ca244 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -668,16 +668,24 @@ export default function CronPage() { }, []); useEffect(() => { + let cancelled = false; api - .getCronDeliveryTargets() - .then((res) => setDeliveryTargets(res.targets)) - .catch(() => + .getCronDeliveryTargets(resourceProfile) + .then((res) => { + if (!cancelled) setDeliveryTargets(res.targets); + }) + .catch(() => { // Fall back to local-only so the modal still works if the endpoint fails. - setDeliveryTargets([ - { id: "local", name: "Local", home_target_set: true, home_env_var: null }, - ]), - ); - }, []); + if (!cancelled) { + setDeliveryTargets([ + { id: "local", name: "Local", home_target_set: true, home_env_var: null }, + ]); + } + }); + return () => { + cancelled = true; + }; + }, [resourceProfile]); useEffect(() => { jobsActiveRef.current = true;