fix: apply profile terminal env in webui sessions

This commit is contained in:
Dennis Soong committed 2026-04-29 14:12:59 +08:00
1 parent 72b4ff66f0
commit 8a74ea89e7
3 files changed
+150 -2

No files matched your search

+83
View File
@@ -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 <profile>``), 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)
+12 -2
View File
@@ -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)
+55
View File
@@ -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