fix(profile): preserve scoped dashboard and launcher behavior
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user