fix: apply profile terminal env in webui sessions
This commit is contained in:
1 parent
72b4ff66f0
commit
8a74ea89e7
3 files changed
+150
-2
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user