From 8a74ea89e77931fe1be15108f1a645526d98fb66 Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Wed, 29 Apr 2026 14:12:59 +0800 Subject: [PATCH] fix: apply profile terminal env in webui sessions --- api/profiles.py | 83 ++++++++++++++++++++++++++++++ api/streaming.py | 14 ++++- tests/test_profile_terminal_env.py | 55 ++++++++++++++++++++ 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 tests/test_profile_terminal_env.py diff --git a/api/profiles.py b/api/profiles.py index 820eb853..a3965991 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -158,6 +158,89 @@ def get_hermes_home_for_profile(name: str) -> Path: return _DEFAULT_HERMES_HOME +_TERMINAL_ENV_MAPPINGS = { + 'backend': 'TERMINAL_ENV', + 'env_type': 'TERMINAL_ENV', + 'cwd': 'TERMINAL_CWD', + 'timeout': 'TERMINAL_TIMEOUT', + 'lifetime_seconds': 'TERMINAL_LIFETIME_SECONDS', + 'modal_mode': 'TERMINAL_MODAL_MODE', + 'docker_image': 'TERMINAL_DOCKER_IMAGE', + 'docker_forward_env': 'TERMINAL_DOCKER_FORWARD_ENV', + 'docker_env': 'TERMINAL_DOCKER_ENV', + 'docker_mount_cwd_to_workspace': 'TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE', + 'singularity_image': 'TERMINAL_SINGULARITY_IMAGE', + 'modal_image': 'TERMINAL_MODAL_IMAGE', + 'daytona_image': 'TERMINAL_DAYTONA_IMAGE', + 'container_cpu': 'TERMINAL_CONTAINER_CPU', + 'container_memory': 'TERMINAL_CONTAINER_MEMORY', + 'container_disk': 'TERMINAL_CONTAINER_DISK', + 'container_persistent': 'TERMINAL_CONTAINER_PERSISTENT', + 'docker_volumes': 'TERMINAL_DOCKER_VOLUMES', + 'persistent_shell': 'TERMINAL_PERSISTENT_SHELL', + 'ssh_host': 'TERMINAL_SSH_HOST', + 'ssh_user': 'TERMINAL_SSH_USER', + 'ssh_port': 'TERMINAL_SSH_PORT', + 'ssh_key': 'TERMINAL_SSH_KEY', + 'ssh_persistent': 'TERMINAL_SSH_PERSISTENT', + 'local_persistent': 'TERMINAL_LOCAL_PERSISTENT', +} + + +def _stringify_env_value(value) -> str: + if isinstance(value, bool): + return 'true' if value else 'false' + if isinstance(value, (list, dict)): + return json.dumps(value) + return str(value) + + +def get_profile_runtime_env(home: Path) -> dict[str, str]: + """Return env vars needed to run an agent turn for a profile home. + + WebUI profile switching is per-client/cookie scoped, so it intentionally + does not call ``switch_profile(..., process_wide=True)`` for every browser. + Agent/tool code still consumes terminal backend settings through + environment variables (matching ``hermes -p ``), so streaming must + apply the selected profile's terminal config and ``.env`` for the duration + of that run. + """ + home = Path(home).expanduser() + env: dict[str, str] = {} + + try: + import yaml as _yaml + + cfg_path = home / 'config.yaml' + cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {} + if not isinstance(cfg, dict): + cfg = {} + except Exception: + cfg = {} + + terminal_cfg = cfg.get('terminal', {}) if isinstance(cfg, dict) else {} + if isinstance(terminal_cfg, dict): + for key, env_key in _TERMINAL_ENV_MAPPINGS.items(): + if key in terminal_cfg and terminal_cfg[key] is not None: + env[env_key] = _stringify_env_value(terminal_cfg[key]) + + env_path = home / '.env' + if env_path.exists(): + try: + for line in env_path.read_text(encoding='utf-8').splitlines(): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if k and v: + env[k] = v + except Exception: + logger.debug("Failed to read runtime env from %s", env_path) + + return env + + def _set_hermes_home(home: Path): """Set HERMES_HOME env var and monkey-patch cached module-level paths.""" os.environ['HERMES_HOME'] = str(home) diff --git a/api/streaming.py b/api/streaming.py index 044a7548..f008bf48 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1155,6 +1155,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta old_exec_ask = None old_session_key = None old_hermes_home = None + old_profile_env = {} # ── MCP Server Discovery (lazy import, idempotent) ── # discover_mcp_tools() is called here (rather than at server startup) so that @@ -1226,12 +1227,16 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta # two concurrent tabs on different profiles don't clobber each other via the # process-level active-profile global. Falls back gracefully. try: - from api.profiles import get_hermes_home_for_profile - _profile_home = str(get_hermes_home_for_profile(getattr(s, 'profile', None))) + from api.profiles import get_hermes_home_for_profile, get_profile_runtime_env + _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) except ImportError: _profile_home = os.environ.get('HERMES_HOME', '') + _profile_runtime_env = {} _set_thread_env( + **_profile_runtime_env, TERMINAL_CWD=str(s.workspace), HERMES_EXEC_ASK='1', HERMES_SESSION_KEY=session_id, @@ -1242,10 +1247,12 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta # 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_cwd = os.environ.get('TERMINAL_CWD') old_exec_ask = os.environ.get('HERMES_EXEC_ASK') old_session_key = os.environ.get('HERMES_SESSION_KEY') old_hermes_home = os.environ.get('HERMES_HOME') + os.environ.update(_profile_runtime_env) os.environ['TERMINAL_CWD'] = str(s.workspace) os.environ['HERMES_EXEC_ASK'] = '1' os.environ['HERMES_SESSION_KEY'] = session_id @@ -2029,6 +2036,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta except Exception: logger.debug("Failed to unregister clarify callback") with _ENV_LOCK: + for _key, _old_value in old_profile_env.items(): + if _old_value is None: os.environ.pop(_key, None) + else: os.environ[_key] = _old_value if old_cwd is None: os.environ.pop('TERMINAL_CWD', None) else: os.environ['TERMINAL_CWD'] = old_cwd if old_exec_ask is None: os.environ.pop('HERMES_EXEC_ASK', None) diff --git a/tests/test_profile_terminal_env.py b/tests/test_profile_terminal_env.py new file mode 100644 index 00000000..1718f40b --- /dev/null +++ b/tests/test_profile_terminal_env.py @@ -0,0 +1,55 @@ +import os +from pathlib import Path + +import yaml + + +def test_profile_runtime_env_includes_terminal_config_and_dotenv(tmp_path): + from api.profiles import get_profile_runtime_env + + home = tmp_path / "profiles" / "server-ops" + home.mkdir(parents=True) + (home / "config.yaml").write_text( + yaml.safe_dump( + { + "terminal": { + "backend": "ssh", + "cwd": "/home/dso2ng/repos", + "timeout": 180, + "ssh_host": "pollux", + "ssh_user": "dso2ng", + "persistent_shell": True, + "lifetime_seconds": 300, + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + (home / ".env").write_text( + "TERMINAL_TIMEOUT=60\n" + "TERMINAL_SSH_HOST=pollux-from-env\n" + "HERMES_MAX_ITERATIONS=90\n", + encoding="utf-8", + ) + + env = get_profile_runtime_env(home) + + assert env["TERMINAL_ENV"] == "ssh" + assert env["TERMINAL_CWD"] == "/home/dso2ng/repos" + assert env["TERMINAL_SSH_USER"] == "dso2ng" + assert env["TERMINAL_PERSISTENT_SHELL"] == "true" + assert env["TERMINAL_LIFETIME_SECONDS"] == "300" + # .env remains the final override source, matching CLI/profile behaviour. + assert env["TERMINAL_TIMEOUT"] == "60" + assert env["TERMINAL_SSH_HOST"] == "pollux-from-env" + assert env["HERMES_MAX_ITERATIONS"] == "90" + + +def test_streaming_applies_profile_runtime_env_to_agent_run(): + src = Path("api/streaming.py").read_text(encoding="utf-8") + + assert "get_profile_runtime_env" in src + assert "_profile_runtime_env" in src + assert "old_profile_env" in src + assert "os.environ.update(_profile_runtime_env)" in src