fix(profile): preserve scoped dashboard and launcher behavior

This commit is contained in:
ethernet
2026-09-23 11:00:15 -04:00
parent 063560696e
commit 025d4155ac
8 changed files with 69 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <p className="text-sm text-red-500">Couldn't load blueprints: {loadError}</p>;

View File

@@ -685,8 +685,10 @@ export const api = {
// Cron jobs
getCronJobs: (profile = "all") =>
fetchJSON<CronJob[]>(`/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<CronJob>(`/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<string, string> },
profile = "default",

View File

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