From f8adefdebf082047527a5fe628c0a4c6f3906a57 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:53:09 -0600 Subject: [PATCH] fix(tui): apply terminal backend config before launch --- hermes_cli/config.py | 122 +++++++++++++++++------ hermes_cli/main.py | 5 + hermes_cli/web_server.py | 5 + tests/hermes_cli/test_tui_resume_flow.py | 40 ++++++++ tests/hermes_cli/test_web_server.py | 33 ++++++ 5 files changed, 175 insertions(+), 30 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f4544038a45..c648c3f05fd 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -13,6 +13,7 @@ This module provides: """ import copy +import json import logging import os import platform @@ -5152,6 +5153,94 @@ def load_config_readonly() -> Dict[str, Any]: return _load_config_impl(want_deepcopy=False) +TERMINAL_CONFIG_ENV_MAP = { + "backend": "TERMINAL_ENV", + "modal_mode": "TERMINAL_MODAL_MODE", + "cwd": "TERMINAL_CWD", + "timeout": "TERMINAL_TIMEOUT", + "lifetime_seconds": "TERMINAL_LIFETIME_SECONDS", + "docker_image": "TERMINAL_DOCKER_IMAGE", + "docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV", + "singularity_image": "TERMINAL_SINGULARITY_IMAGE", + "modal_image": "TERMINAL_MODAL_IMAGE", + "daytona_image": "TERMINAL_DAYTONA_IMAGE", + "ssh_host": "TERMINAL_SSH_HOST", + "ssh_user": "TERMINAL_SSH_USER", + "ssh_port": "TERMINAL_SSH_PORT", + "ssh_key": "TERMINAL_SSH_KEY", + "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", + "docker_env": "TERMINAL_DOCKER_ENV", + "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", + "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS", + "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", + "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", + "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", + "sandbox_dir": "TERMINAL_SANDBOX_DIR", + "persistent_shell": "TERMINAL_PERSISTENT_SHELL", +} + + +def _terminal_env_value(value: Any) -> str: + if isinstance(value, (list, dict)): + return json.dumps(value) + return str(value) + + +def terminal_config_env_var_for_key(key: str) -> Optional[str]: + """Return the env var mirrored by a ``terminal.*`` config key.""" + prefix = "terminal." + if not key.startswith(prefix): + return None + return TERMINAL_CONFIG_ENV_MAP.get(key[len(prefix):]) + + +def apply_terminal_config_to_env( + *, + env: Optional[Dict[str, str]] = None, + config: Optional[Dict[str, Any]] = None, + override: Optional[bool] = None, +) -> Dict[str, str]: + """Bridge ``terminal.*`` config into the env vars terminal tools read. + + ``tools.terminal_tool`` is intentionally environment-driven because it also + runs in child processes (TUI, dashboard PTY, gateway workers). This helper + gives those child-process launch paths the same config bridge as classic + CLI without importing ``cli.py`` and paying for its startup side effects. + + When the user config contains a ``terminal`` section, config.yaml is + authoritative and overrides existing env values. Otherwise defaults only + backfill missing env vars so exported/.env values keep working. + """ + target = os.environ if env is None else env + + raw_config = read_raw_config() + file_has_terminal_config = isinstance(raw_config.get("terminal"), dict) + should_override = file_has_terminal_config if override is None else override + + cfg = config if config is not None else load_config_readonly() + terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {} + if not isinstance(terminal_cfg, dict): + return target + + for cfg_key, env_var in TERMINAL_CONFIG_ENV_MAP.items(): + if cfg_key not in terminal_cfg: + continue + value = terminal_cfg[cfg_key] + if cfg_key == "cwd": + raw_cwd = str(value or "").strip() + if raw_cwd in {".", "auto", "cwd"}: + continue + if isinstance(value, str): + value = os.path.expanduser(value) + if should_override or env_var not in target: + target[env_var] = _terminal_env_value(value) + return target + + def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]: with _CONFIG_LOCK: ensure_hermes_home() @@ -6040,36 +6129,9 @@ def set_config_value(key: str, value: str): # Keep .env in sync for keys that terminal_tool reads directly from env vars. # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc. - _config_to_env_sync = { - "terminal.backend": "TERMINAL_ENV", - "terminal.modal_mode": "TERMINAL_MODAL_MODE", - "terminal.docker_image": "TERMINAL_DOCKER_IMAGE", - "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", - "terminal.modal_image": "TERMINAL_MODAL_IMAGE", - "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE", - "terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", - "terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", - "terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", - "terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", - "terminal.docker_env": "TERMINAL_DOCKER_ENV", - # JSON-valued keys (terminal_tool parses these via json.loads). The user - # passes JSON on the CLI, so str(value) below already yields valid JSON — - # same as terminal.docker_env. cli.py and gateway/run.py bridge these too. - "terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES", - "terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV", - # terminal.cwd intentionally excluded — CLI resolves at runtime, - # gateway bridges it in gateway/run.py. Persisting to .env causes - # stale values to poison child processes. - "terminal.timeout": "TERMINAL_TIMEOUT", - "terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR", - "terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL", - "terminal.container_cpu": "TERMINAL_CONTAINER_CPU", - "terminal.container_memory": "TERMINAL_CONTAINER_MEMORY", - "terminal.container_disk": "TERMINAL_CONTAINER_DISK", - "terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT", - } - if key in _config_to_env_sync: - save_env_value(_config_to_env_sync[key], str(value)) + env_var = terminal_config_env_var_for_key(key) + if env_var and key != "terminal.cwd": + save_env_value(env_var, _terminal_env_value(value)) print(f"✓ Set {key} = {value} in {config_path}") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9439f16dedb..334c603e856 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1825,6 +1825,11 @@ def _launch_tui( import tempfile env = os.environ.copy() + try: + from hermes_cli.config import apply_terminal_config_to_env + apply_terminal_config_to_env(env=env) + except Exception: + logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True) active_session_fd, active_session_file = tempfile.mkstemp( prefix="hermes-tui-active-session-", suffix=".json" ) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2b4034b2ec5..08d0ac32bda 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -8573,6 +8573,11 @@ def _resolve_chat_argv( argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False) env = os.environ.copy() + try: + from hermes_cli.config import apply_terminal_config_to_env + apply_terminal_config_to_env(env=env) + except Exception: + _log.debug("Failed to apply terminal config bridge for dashboard chat", exc_info=True) env.setdefault("NODE_ENV", "production") # Browser-embedded chat should prefer stable wheel-based scrollback over # native terminal mouse tracking. When mouse tracking is enabled, wheel diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index d15d67c0071..ad8ffbe79b8 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -896,6 +896,46 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod): assert env["NODE_ENV"] == "production" +def test_launch_tui_applies_terminal_backend_config( + monkeypatch, main_mod, _isolate_hermes_home +): + captured = {} + config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml" + config_path.write_text( + "\n".join( + [ + "terminal:", + " backend: docker", + " docker_image: example/hermes-tools:latest", + " docker_extra_args:", + " - --network=host", + ] + ), + encoding="utf-8", + ) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False) + monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False) + + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), + ) + monkeypatch.setattr( + main_mod.subprocess, + "call", + lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1, + ) + + with pytest.raises(SystemExit): + main_mod._launch_tui() + + assert captured["env"]["TERMINAL_ENV"] == "docker" + assert captured["env"]["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest" + assert captured["env"]["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]' + + def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod): from unittest.mock import patch diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 2d9cd5a5ce2..50314debfc1 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4146,6 +4146,39 @@ class TestPtyWebSocket: assert env["HERMES_TUI_INLINE"] == "1" assert env["HERMES_TUI_DISABLE_MOUSE"] == "1" + def test_resolve_chat_argv_applies_terminal_backend_config( + self, monkeypatch, _isolate_hermes_home + ): + import hermes_cli.main as main_mod + + config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml" + config_path.write_text( + "\n".join( + [ + "terminal:", + " backend: docker", + " docker_image: example/hermes-tools:latest", + " docker_extra_args:", + " - --network=host", + ] + ), + encoding="utf-8", + ) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False) + monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False) + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"), + ) + + _argv, _cwd, env = self.ws_module._resolve_chat_argv() + + assert env["TERMINAL_ENV"] == "docker" + assert env["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest" + assert env["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]' + def test_rejects_when_embedded_chat_disabled(self, monkeypatch): monkeypatch.setattr(self.ws_module, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", False) from starlette.websockets import WebSocketDisconnect