{"repo":"NousResearch/hermes-agent","public_id":"54449","display_label":"terminal.backend config not bridged to TERMINAL_ENV","summary":"Multiple bugs and fixes around terminal.backend configuration not being propagated to TERMINAL_ENV across serve, TUI, desktop, and oneshot processes. This causes the configured backend (e.g. Docker) to be silently ignored in favor of the local host environment.","subsystem":"dashboard","counts":{"members":14,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65760},"resolution":{"state":"partially_resolved","by":"pr-65760"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T15:55:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14769230769230768},"fix_edges":[{"issue_number":54449,"pr_number":65760,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61115,"pr_number":65760,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":63141,"pr_number":65760,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":65696,"pr_number":65760,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":54449,"title":"[Bug]: Desktop/TUI dashboard process doesn't bridge terminal.backend -> TERMINAL_ENV, so the gateway registers the host cwd instead of /workspace","url":null,"state":"closed"},{"kind":"issue","number":63141,"title":"[Bug]: hermes serve doesn't bridge terminal.backend config to TERMINAL_ENV, ignoring Docker backend","url":null,"state":"closed"},{"kind":"issue","number":65696,"title":"Desktop app's cron ticker runs jobs without the terminal config bridge — sandbox silently bypassed (terminal.backend: docker ignored, file/terminal ops run on host)","url":null,"state":"closed"},{"kind":"issue","number":66019,"title":"hermes -z (oneshot) silently ignores terminal.backend and executes tools with the local backend — sandbox bypass","url":null,"state":"closed"},{"kind":"pr","number":54526,"title":"fix(tui): fall back to config terminal.backend when TERMINAL_ENV is unset in dashboard/TUI process","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65760","number":65760}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":63150,"title":"fix(tools): bridge terminal.backend config to TERMINAL_ENV in serve process","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65760","number":65760},{"kind":"pr","value":"#63141","number":63141}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":63332,"title":"fix(serve): bridge terminal.backend config to TERMINAL_ENV in start_server()","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":63613,"title":"fix(serve): bridge terminal.backend config to TERMINAL_ENV in start_server","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":63950,"title":"fix(gateway): prevent goal continuation loop on agent failure (#63180)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63180","number":63180},{"kind":"pr","value":"#63150","number":63150},{"kind":"pr","value":"#63953","number":63953},{"kind":"pr","value":"#63141","number":63141},{"kind":"pr","value":"#63150","number":63150}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":63953,"title":"fix(serve): bridge terminal.backend config to TERMINAL_ENV in start_server (#63141)","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":64096,"title":"fix(serve): bridge terminal.backend config to TERMINAL_ENV in start_server()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63150","number":63150},{"kind":"pr","value":"#63141","number":63141},{"kind":"pr","value":"#63150","number":63150},{"kind":"pr","value":"#63332","number":63332},{"kind":"pr","value":"#63613","number":63613},{"kind":"pr","value":"#63953","number":63953}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":64130,"title":"fix(serve): bridge terminal.backend config to process environment","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":65751,"title":"fix(desktop): apply terminal config before cron ticks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65760","number":65760},{"kind":"pr","value":"#65696","number":65696}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":65760,"title":"fix(terminal): honor terminal.backend config in serve/desktop processes lacking a launcher env bridge","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/tools/test_terminal_env_bridge.py","tools/terminal_tool.py","tui_gateway/server.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -12350,6 +12350,23 @@ def cmd_dashboard(args):\n     # backend is the desktop's primary entrypoint and needs the same.\n     _sync_bundled_skills_quietly()\n \n+    # Bridge terminal.* config into the TERMINAL_* env vars for THIS process,\n+    # mirroring the CLI (cli.py env_mappings) and gateway (gateway/run.py\n+    # _terminal_env_map) startup bridges. The dashboard/serve backend runs\n+    # agents in-process (tui_gateway.ws → server._make_agent) and ticks cron\n+    # jobs itself when desktop-spawned — without this bridge those consumers\n+    # saw an unset TERMINAL_ENV and silently ran every command on the host\n+    # even when config.yaml selects `terminal.backend: docker`\n+    # (#63141, #54449, #61115, #65696). PTY chat spawns already bridge their\n+    # child env copy; this covers the in-process consumers.\n+    try:\n+        from hermes_cli.config import apply_terminal_config_to_env\n+\n+        apply_terminal_config_to_env()\n+    except Exception:\n+        logger.debug(\"terminal config → env bridge failed for dashboard/serve\",\n+                     exc_info=True)\n+\n     if _headless_backend:\n         # Don't build the SPA, and tell mount_spa() (read at web_server import\n         # below) to disable it even if a stray dist exists. Set it first."]},{"file":"tests/tools/test_terminal_env_bridge.py","hunks":["@@ -0,0 +1,119 @@\n+\"\"\"Regression tests for the terminal config → env fallback bridge.\n+\n+``terminal_tool._get_env_config()`` reads all settings from TERMINAL_* env\n+vars, which the CLI / gateway / TUI-PTY launchers bridge from config.yaml at\n+startup. Processes that skip every launcher bridge (``hermes serve`` and the\n+Desktop app's in-process agents, the desktop cron ticker, ACP) used to fall\n+back silently to the local backend even when config.yaml selected\n+``terminal.backend: docker`` — commands the user intended to sandbox ran on\n+the host (#63141 / #54449 / #61115 / #65696).\n+\n+``_ensure_terminal_env_bridged()`` closes that hole at the chokepoint: when\n+TERMINAL_ENV is unset, backfill TERMINAL_* from config.yaml before the\n+local default applies. An explicitly-set TERMINAL_ENV always wins.\n+\"\"\"\n+\n+import os\n+\n+import pytest\n+\n+import tools.terminal_tool as terminal_tool\n+from hermes_constants import get_hermes_home\n+\n+\n[redacted:email](autouse=True)\n+def _reset_bridge_state(monkeypatch):\n+    \"\"\"Each test starts with an un-attempted bridge and no TERMINAL_ENV.\"\"\"\n+    monkeypatch.setattr(terminal_tool, \"_terminal_config_bridge_attempted\", False)\n+    monkeypatch.delenv(\"TERMINAL_ENV\", raising=False)\n+    monkeypatch.delenv(\"TERMINAL_CWD\", raising=False)\n+    monkeypatch.delenv(\"TERMINAL_DOCKER_IMAGE\", raising=False)\n+    # The config layer caches by (path, mtime, size); leave it alone — each\n+    # test writes its own config.yaml which changes the signature.\n+    yield\n+\n+\n+def _write_config(text: str) -> None:\n+    home = get_hermes_home()\n+    home.mkdir(parents=True, exist_ok=True)\n+    (home / \"config.yaml\").write_text(text)\n+\n+\n+def test_unset_terminal_env_backfills_backend_from_config():\n+    \"\"\"The core #63141 fix: config's docker backend reaches _get_env_config\n+    even when no launcher bridged TERMINAL_ENV into this process.\"\"\"\n+    _write_config(\n+        \"terminal:\\n\"\n+        \"  backend: docker\\n\"\n+        \"  docker_image: custom/image:1\\n\"\n+    )\n+\n+    config = terminal_tool._get_env_config()\n+\n+    assert config[\"env_type\"] == \"docker\"\n+    assert config[\"docker_image\"] == \"custom/image:1\"\n+    assert os.environ.get(\"TERMINAL_ENV\") == \"docker\"\n+\n+\n+def test_explicit_terminal_env_wins_over_config(monkeypatch):\n+    \"\"\"An explicit env choice (launcher bridge or .env) is never overridden —\n+    honor explicit choice vs accidental fallback.\"\"\"\n+    _write_config(\"terminal:\\n  backend: docker\\n\")\n+    monkeypatch.setenv(\"TERMINAL_ENV\", \"local\")\n+\n+    config = terminal_tool._get_env_config()\n+\n+    assert config[\"env_type\"] == \"local\"\n+\n+\n+def test_preset_terminal_vars_survive_backfill(monkeypatch):\n+    \"\"\"override=False: already-set sibling TERMINAL_* values stay\n+    authoritative; only missing ones are backfilled.\"\"\"\n+    _write_config(\n+        \"terminal:\\n\"\n+        \"  backend: docker\\n\"\n+        \"  docker_image: config/image:1\\n\"\n+    )\n+    monkeypatch.setenv(\"TERMINAL_DOCKER_IMAGE\", \"env/image:2\")\n+\n+    config = terminal_tool._get_env_config()\n+\n+    assert config[\"env_type\"] == \"docker\"\n+    assert config[\"docker_image\"] == \"env/image:2\"\n+\n+\n+def test_bridge_failure_falls_back_to_local(monkeypatch):\n+    \"\"\"A broken config layer must not take the terminal tool down.\"\"\"\n+\n+    def _boom(*_a, **_k):\n+        raise RuntimeError(\"config exploded\")\n+\n+    import hermes_cli.config as config_mod\n+\n+    monkeypatch.setattr(config_mod, \"apply_terminal_config_to_env\", _boom)\n+\n+    config = terminal_tool._get_env_config()\n+\n+    assert config[\"env_type\"] == \"local\"\n+\n+\n+def test_bridge_only_attempted_once(monkeypatch):\n+    \"\"\"The config load runs at most once per process when TERMINAL_ENV stays\n+    unset (e.g. empty config) — later calls skip the bridge entirely.\"\"\"\n+    calls = []\n+\n+    import hermes_cli.config as config_mod\n+\n+    real = config_mod.apply_terminal_config_to_env\n+\n+    def _counting(*a, **k):\n+        calls.append(1)\n+        return real(*a, **k)\n+\n+    monkeypatch.setattr(config_mod, \"apply_terminal_config_to_env\", _counting)\n+    _write_config(\"{}\\n\")\n+\n+    terminal_tool._get_env_config()\n+    terminal_tool._get_env_config()\n+\n+    assert len(calls) == 1"]},{"file":"tools/terminal_tool.py","hunks":["@@ -1305,10 +1305,52 @@ def _is_unusable_container_cwd(cwd: str) -> bool:\n     return False\n \n \n+# One-shot guard for the config-fallback bridge below.  Purely an\n+# optimization: after the first attempt either TERMINAL_ENV is set (bridge\n+# succeeded — merged config always carries terminal.backend) or the import\n+# failed and retrying every call would be wasted work.\n+_terminal_config_bridge_attempted = False\n+\n+\n+def _ensure_terminal_env_bridged() -> None:\n+    \"\"\"Backfill TERMINAL_* env vars from config.yaml when no launcher did.\n+\n+    terminal_tool reads ALL terminal settings from os.environ (TERMINAL_*).\n+    The CLI (cli.py ``env_mappings``), the gateway (gateway/run.py\n+    ``_terminal_env_map``), and TUI/dashboard PTY launches\n+    (``apply_terminal_config_to_env``) bridge ``terminal.*`` config into env\n+    vars at startup — but processes that skip all of those paths (``hermes\n+    serve`` / the Desktop app backend's in-process agents, the desktop cron\n+    ticker, ACP) used to silently fall back to the local backend even when\n+    config.yaml selects ``terminal.backend: docker``, running commands on the\n+    host the user intended to sandbox (#63141, #54449, #61115, #65696).\n+\n+    Explicit env always wins: when TERMINAL_ENV is already set (a launcher's\n+    bridge or the user's .env made a deliberate choice) this is a no-op.  The\n+    config bridge only fills the unset case, so it changes an accidental\n+    default — never an explicit selection.\n+    \"\"\"\n+    global _terminal_config_bridge_attempted\n+    if \"TERMINAL_ENV\" in os.environ or _terminal_config_bridge_attempted:\n+        return\n+    _terminal_config_bridge_attempted = True\n+    try:\n+        from hermes_cli.config import apply_terminal_config_to_env\n+\n+        # env=None targets os.environ inside the helper; override=False keeps\n+        # any already-set TERMINAL_* values (e.g. from .env) authoritative.\n+        apply_terminal_config_to_env(env=None, override=False)\n+    except Exception:\n+        # Never let a config problem take the terminal tool down — the\n+        # historical local default still applies.\n+        logger.debug(\"terminal config → env fallback bridge failed\", exc_info=True)\n+\n+\n def _get_env_config() -> Dict[str, Any]:\n     \"\"\"Get terminal environment configuration from environment variables.\"\"\"\n     # Default image with Python and Node.js for maximum compatibility\n     default_image = \"nikolaik/python-nodejs:python3.11-nodejs20\"\n+    _ensure_terminal_env_bridged()\n     env_type = os.getenv(\"TERMINAL_ENV\", \"local\")\n     \n     mount_docker_cwd = os.getenv(\"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\", \"false\").lower() in {\"true\", \"1\", \"yes\"}"]},{"file":"tui_gateway/server.py","hunks":["@@ -1542,8 +1542,25 @@ def _terminal_task_cwd(session: dict | None) -> str:\n     point at nonsense.  Non-local terminal backends are different: their cwd is\n     inside the target environment, so an SSH path like /home/user/workspace may\n     not exist on the local macOS host but is still the correct execution cwd.\n+\n+    When ``TERMINAL_ENV`` is unset (dashboard/TUI process) the config's\n+    ``terminal.backend`` is consulted as a fallback so the non-local cwd\n+    resolution path is taken even when the dashboard entrypoint did not call\n+    ``apply_terminal_config_to_env`` on its own ``os.environ``.\n     \"\"\"\n     backend = (os.environ.get(\"TERMINAL_ENV\") or \"\").strip().lower()\n+    if not backend or backend == \"local\":\n+        # Fall back to config when TERMINAL_ENV is unset (dashboard/TUI process\n+        # never calls apply_terminal_config_to_env on os.environ).\n+        try:\n+            terminal_cfg = _load_cfg().get(\"terminal\", {})\n+            if isinstance(terminal_cfg, dict):\n+                cfg_backend = str(terminal_cfg.get(\"backend\") or \"\").strip().lower()\n+                if cfg_backend and cfg_backend != \"local\":\n+                    backend = cfg_backend\n+        except Exception:\n+            pass\n+\n     if backend and backend != \"local\":\n         raw = os.environ.get(\"TERMINAL_CWD\", \"\").strip()\n         if not raw:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}