Merge pull request #4436 from nesquena/release/stage-4432
Release v0.51.498 — RH (profile runtime env no longer clobbers core shell identity, #4432)
This commit is contained in:
commit
dbc7b317e7
4 changed files with 86 additions and 6 deletions
|
|
@ -3,6 +3,12 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.498] — 2026-06-18 — Release RH (profile runtime env no longer clobbers core shell identity)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A profile's runtime env no longer overrides the WebUI server's own core shell-identity variables during a turn.** When a profile-scoped turn projected the profile's `config.yaml` terminal settings and `.env` into the process environment, a profile that set `HOME`, `PATH`, `PWD`, `SHELL`, `USER`, `PYTHONPATH`, `VIRTUAL_ENV`, `LD_LIBRARY_PATH`, or an `XDG_*` var could clobber the running server's identity/import path for the duration of the run. WebUI now filters those core-identity keys out of the projected profile env (matching Hermes Agent gateway semantics) while still projecting credentials, `HERMES_HOME`, and `TERMINAL_*` settings. Thanks @lunarnexus.
|
||||
|
||||
## [v0.51.497] — 2026-06-18 — Release RG (rollback checkpoint diffs no longer follow symlinks)
|
||||
|
||||
### Security
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue