diff --git a/api/profiles.py b/api/profiles.py index e4050edd..53965ec2 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -696,6 +696,40 @@ def get_profile_runtime_env(home: Path) -> dict[str, str]: return env +# Match Hermes Agent gateway behavior: profile-scoped WebUI runs should +# project intended runtime vars (credentials, HERMES_HOME, TERMINAL_*) +# without allowing profile env to override core shell identity variables +# like HOME or PATH. +_BLOCKED_RUNTIME_ENV_KEYS = { + 'HOME', + 'PATH', + 'PWD', + 'SHELL', + 'USER', + 'LOGNAME', + 'SHLVL', + 'OLDPWD', + 'PYTHONPATH', + 'VIRTUAL_ENV', + 'LD_LIBRARY_PATH', +} + + +def filter_runtime_env_for_gateway_parity(env: dict[str, str]) -> dict[str, str]: + """Return a profile runtime env filtered to mimic Hermes gateway semantics.""" + filtered: dict[str, str] = {} + for key, value in (env or {}).items(): + k = str(key).strip() + if not k: + continue + if k in _BLOCKED_RUNTIME_ENV_KEYS: + continue + if k.startswith('XDG_'): + continue + filtered[k] = value + return filtered + + @contextmanager def profile_env_for_background_worker( session, @@ -724,6 +758,7 @@ def profile_env_for_background_worker( profile_home_path = Path(get_hermes_home_for_profile(profile)) runtime_env = get_profile_runtime_env(profile_home_path) + safe_runtime_env = filter_runtime_env_for_gateway_parity(runtime_env) except Exception: log.debug( "Failed to resolve profile env for %s profile %s; falling back to current env", @@ -734,7 +769,7 @@ def profile_env_for_background_worker( yield return - thread_env = dict(runtime_env) + thread_env = dict(safe_runtime_env) thread_env["HERMES_HOME"] = str(profile_home_path) # Hybrid profile routing: keep the broad runtime env in WebUI's thread-local # channel for WebUI helpers, and also mirror it into process env for the @@ -749,11 +784,11 @@ def profile_env_for_background_worker( try: _set_thread_env(**thread_env) with _ENV_LOCK: - old_runtime_env = {key: os.environ.get(key) for key in runtime_env} + old_runtime_env = {key: os.environ.get(key) for key in safe_runtime_env} had_hermes_home = "HERMES_HOME" in os.environ old_hermes_home = os.environ.get("HERMES_HOME") skill_home_snapshot = snapshot_skill_home_modules() - os.environ.update(runtime_env) + os.environ.update(safe_runtime_env) os.environ["HERMES_HOME"] = str(profile_home_path) try: patch_skill_home_modules(profile_home_path) diff --git a/api/streaming.py b/api/streaming.py index 7984aaa3..b9b63fa5 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -5856,6 +5856,7 @@ def _run_agent_streaming( # process-level active-profile global. Falls back gracefully. try: from api.profiles import ( + filter_runtime_env_for_gateway_parity, patch_skill_home_modules, get_hermes_home_for_profile, get_profile_runtime_env, @@ -5863,9 +5864,11 @@ def _run_agent_streaming( _profile_home_path = get_hermes_home_for_profile(getattr(s, 'profile', None)) _profile_home = str(_profile_home_path) _profile_runtime_env = get_profile_runtime_env(_profile_home_path) + _safe_profile_runtime_env = filter_runtime_env_for_gateway_parity(_profile_runtime_env) except ImportError: _profile_home = os.environ.get('HERMES_HOME', '') _profile_runtime_env = {} + _safe_profile_runtime_env = {} patch_skill_home_modules = None # Profile-aware provider/model enrichment: when the session belongs @@ -5922,7 +5925,7 @@ def _run_agent_streaming( # The finally block re-acquires to restore — keeping critical sections short # and preventing a deadlock where the restore would re-enter the same lock. with _ENV_LOCK: - old_profile_env = {key: os.environ.get(key) for key in _profile_runtime_env} + old_profile_env = {key: os.environ.get(key) for key in _safe_profile_runtime_env} old_cwd = os.environ.get('TERMINAL_CWD') old_exec_ask = os.environ.get('HERMES_EXEC_ASK') old_session_key = os.environ.get('HERMES_SESSION_KEY') @@ -5930,7 +5933,7 @@ def _run_agent_streaming( old_session_platform = os.environ.get('HERMES_SESSION_PLATFORM') old_session_chat_id = os.environ.get('HERMES_SESSION_CHAT_ID') old_hermes_home = os.environ.get('HERMES_HOME') - os.environ.update(_profile_runtime_env) + os.environ.update(_safe_profile_runtime_env) os.environ['TERMINAL_CWD'] = str(s.workspace) os.environ['HERMES_EXEC_ASK'] = '1' os.environ['HERMES_SESSION_KEY'] = session_id diff --git a/tests/test_profile_terminal_env.py b/tests/test_profile_terminal_env.py index 1614d6b6..daff695a 100644 --- a/tests/test_profile_terminal_env.py +++ b/tests/test_profile_terminal_env.py @@ -52,8 +52,44 @@ def test_streaming_applies_profile_runtime_env_to_agent_run(): assert "get_profile_runtime_env" in src assert "_profile_runtime_env" in src + assert "_safe_profile_runtime_env" in src assert "old_profile_env" in src - assert "os.environ.update(_profile_runtime_env)" in src + assert "filter_runtime_env_for_gateway_parity" in src + assert "os.environ.update(_safe_profile_runtime_env)" in src + assert "os.environ.update(_profile_runtime_env)" not in src + + +def test_filter_runtime_env_for_gateway_parity_blocks_shell_identity_vars(): + from api.profiles import filter_runtime_env_for_gateway_parity + + env = { + "HOME": "/tmp/fake-home", + "PATH": "/tmp/fake-bin:/usr/bin", + "PWD": "/tmp/fake-pwd", + "SHELL": "/bin/zsh", + "OPENAI_API_KEY": "test-key", + "TERMINAL_ENV": "ssh", + "TERMINAL_CWD": "/workspace", + } + + filtered = filter_runtime_env_for_gateway_parity(env) + + assert "HOME" not in filtered + assert "PATH" not in filtered + assert "PWD" not in filtered + assert "SHELL" not in filtered + assert filtered["OPENAI_API_KEY"] == "test-key" + assert filtered["TERMINAL_ENV"] == "ssh" + assert filtered["TERMINAL_CWD"] == "/workspace" + + +def test_profile_background_worker_uses_gateway_parity_runtime_env_filter(): + src = Path("api/profiles.py").read_text(encoding="utf-8") + + assert "filter_runtime_env_for_gateway_parity" in src + assert "safe_runtime_env" in src + assert "os.environ.update(safe_runtime_env)" in src + assert "os.environ.update(runtime_env)" not in src def test_streaming_thread_env_allows_profile_terminal_cwd_override():