# NousResearch/hermes-agent · terminal.backend config not bridged to TERMINAL_ENV

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 14 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 1 merged PR(s), 9 PR(s) closed without merging.
- A fix was merged to main in PR #65760. Verify it addresses your case.
- **Recommended:** read PR #65760 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-65760`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.15`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #54449: \[Bug\]: Desktop/TUI dashboard process doesn't bridge terminal.backend -> TERMINAL_ENV, so the gateway registers the host cwd instead of /workspace
- #63141: \[Bug\]: hermes serve doesn't bridge terminal.backend config to TERMINAL_ENV, ignoring Docker backend
- #65696: 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)
- #66019: hermes -z (oneshot) silently ignores terminal.backend and executes tools with the local backend — sandbox bypass

## Prior & competing PRs

- **#63150: fix(tools): bridge terminal.backend config to TERMINAL_ENV in serve process**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#65760`, `#63141`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#64130: fix(serve): bridge terminal.backend config to process environment**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#65751: fix(desktop): apply terminal config before cron ticks**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#65760`, `#65696`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#65760: fix(terminal): honor terminal.backend config in serve/desktop processes lacking a launcher env bridge**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `tests/tools/test_terminal_env_bridge.py`, `tools/terminal_tool.py`, `tui_gateway/server.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -12350,6 +12350,23 @@ def cmd_dashboard(args):
         # backend is the desktop's primary entrypoint and needs the same.
         _sync_bundled_skills_quietly()
     
    +    # Bridge terminal.* config into the TERMINAL_* env vars for THIS process,
    +    # mirroring the CLI (cli.py env_mappings) and gateway (gateway/run.py
    +    # _terminal_env_map) startup bridges. The dashboard/serve backend runs
    +    # agents in-process (tui_gateway.ws → server._make_agent) and ticks cron
    +    # jobs itself when desktop-spawned — without this bridge those consumers
    +    # saw an unset TERMINAL_ENV and silently ran every command on the host
    +    # even when config.yaml selects `terminal.backend: docker`
    +    # (#63141, #54449, #61115, #65696). PTY chat spawns already bridge their
    +    # child env copy; this covers the in-process consumers.
    +    try:
    +        from hermes_cli.config import apply_terminal_config_to_env
    +
    +        apply_terminal_config_to_env()
    +    except Exception:
    +        logger.debug("terminal config → env bridge failed for dashboard/serve",
    +                     exc_info=True)
    +
         if _headless_backend:
             # Don't build the SPA, and tell mount_spa() (read at web_server import
             # below) to disable it even if a stray dist exists. Set it first.
    ```
  - `tests/tools/test_terminal_env_bridge.py`

    ```diff
    @@ -0,0 +1,119 @@
    +"""Regression tests for the terminal config → env fallback bridge.
    +
    +``terminal_tool._get_env_config()`` reads all settings from TERMINAL_* env
    +vars, which the CLI / gateway / TUI-PTY launchers bridge from config.yaml at
    +startup. Processes that skip every launcher bridge (``hermes serve`` and the
    +Desktop app's in-process agents, the desktop cron ticker, ACP) used to fall
    +back silently to the local backend even when config.yaml selected
    +``terminal.backend: docker`` — commands the user intended to sandbox ran on
    +the host (#63141 / #54449 / #61115 / #65696).
    +
    +``_ensure_terminal_env_bridged()`` closes that hole at the chokepoint: when
    +TERMINAL_ENV is unset, backfill TERMINAL_* from config.yaml before the
    +local default applies. An explicitly-set TERMINAL_ENV always wins.
    +"""
    +
    +import os
    +
    +import pytest
    +
    +import tools.terminal_tool as terminal_tool
    +from hermes_constants import get_hermes_home
    +
    +
    [redacted:email](autouse=True)
    +def _reset_bridge_state(monkeypatch):
    +    """Each test starts with an un-attempted bridge and no TERMINAL_ENV."""
    +    monkeypatch.setattr(terminal_tool, "_terminal_config_bridge_attempted", False)
    +    monkeypatch.delenv("TERMINAL_ENV", raising=False)
    +    monkeypatch.delenv("TERMINAL_CWD", raising=False)
    +    monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False)
    +    # The config layer caches by (path, mtime, size); leave it alone — each
    +    # test writes its own config.yaml which changes the signature.
    +    yield
    +
    +
    +def _write_config(text: str) -> None:
    +    home = get_hermes_home()
    +    home.mkdir(parents=True, exist_ok=True)
    +    (home / "config.yaml").write_text(text)
    +
    +
    +def test_unset_terminal_env_backfills_backend_from_config():
    +    """The core #63141 fix: config's docker backend reaches _get_env_config
    +    even when no launcher bridged TERMINAL_ENV into this process."""
    +    _write_config(
    +        "terminal:\n"
    +        "  backend: docker\n"
    +        "  docker_image: custom/image:1\n"
    +    )
    +
    +    config = terminal_tool._get_env_config()
    +
    +    assert config["env_type"] == "docker"
    +    assert config["docker_image"] == "custom/image:1"
    +    assert os.environ.get("TERMINAL_ENV") == "docker"
    +
    +
    +def test_explicit_terminal_env_wins_over_config(monkeypatch):
    +    """An explicit env choice (launcher bridge or .env) is never overridden —
    +    honor explicit choice vs accidental fallback."""
    +    _write_config("terminal:\n  backend: docker\n")
    +    monkeypatch.setenv("TERMINAL_ENV", "local")
    +
    +    config = terminal_tool._get_env_config()
    +
    +    assert config["env_type"] == "local"
    +
    +
    +def test_preset_terminal_vars_survive_backfill(monkeypatch):
    +    """override=False: already-set sibling TERMINAL_* values stay
    +    authoritative; only missing ones are backfilled."""
    +    _write_config(
    +        "terminal:\n"
    +        "  backend: docker\n"
    +        "  docker_image: config/image:1\n"
    +    )
    +    monkeypatch.setenv("TERMINAL_DOCKER_IMAGE", "env/image:2")
    +
    +    config = terminal_tool._get_env_config()
    +
    +    assert config["env_type"] == "docker"
    +    assert config["docker_image"] == "env/image:2"
    +
    +
    +def test_bridge_failure_falls_back_to_local(monkeypatch):
    +    """A broken config layer must not take the terminal tool down."""
    +
    +    def _boom(*_a, **_k):
    +        raise RuntimeError("config exploded")
    +
    +    import hermes_cli.config as config_mod
    +
    +    monkeypatch.setattr(config_mod, "apply_terminal_config_to_env", _boom)
    +
    +    config = terminal_tool._get_env_config()
    +
    +    assert config["env_type"] == "local"
    +
    +
    +def test_bridge_only_attempted_once(monkeypatch):
    +    """The config load runs at most once per process when TERMINAL_ENV stays
    +    unset (e.g. empty config) — later calls skip the bridge entirely."""
    +    calls = []
    +
    +    import hermes_cli.config as config_mod
    +
    +    real = config_mod.apply_terminal_config_to_env
    +
    +    def _counting(*a, **k):
    +        calls.append(1)
    +        return real(*a, **k)
    +
    +    monkeypatch.setattr(config_mod, "apply_terminal_config_to_env", _counting)
    +    _write_config("{}\n")
    +
    +    terminal_tool._get_env_config()
    +    terminal_tool._get_env_config()
    +
    +    assert len(calls) == 1
    ```
  - `tools/terminal_tool.py`

    ```diff
    @@ -1305,10 +1305,52 @@ def _is_unusable_container_cwd(cwd: str) -> bool:
         return False
     
     
    +# One-shot guard for the config-fallback bridge below.  Purely an
    +# optimization: after the first attempt either TERMINAL_ENV is set (bridge
    +# succeeded — merged config always carries terminal.backend) or the import
    +# failed and retrying every call would be wasted work.
    +_terminal_config_bridge_attempted = False
    +
    +
    +def _ensure_terminal_env_bridged() -> None:
    +    """Backfill TERMINAL_* env vars from config.yaml when no launcher did.
    +
    +    terminal_tool reads ALL terminal settings from os.environ (TERMINAL_*).
    +    The CLI (cli.py ``env_mappings``), the gateway (gateway/run.py
    +    ``_terminal_env_map``), and TUI/dashboard PTY launches
    +    (``apply_terminal_config_to_env``) bridge ``terminal.*`` config into env
    +    vars at startup — but processes that skip all of those paths (``hermes
    +    serve`` / the Desktop app backend's in-process agents, the desktop cron
    +    ticker, ACP) used to silently fall back to the local backend even when
    +    config.yaml selects ``terminal.backend: docker``, running commands on the
    +    host the user intended to sandbox (#63141, #54449, #61115, #65696).
    +
    +    Explicit env always wins: when TERMINAL_ENV is already set (a launcher's
    +    bridge or the user's .env made a deliberate choice) this is a no-op.  The
    +    config bridge only fills the unset case, so it changes an accidental
    +    default — never an explicit selection.
    +    """
    +    global _terminal_config_bridge_attempted
    +    if "TERMINAL_ENV" in os.environ or _terminal_config_bridge_attempted:
    +        return
    +    _terminal_config_bridge_attempted = True
    +    try:
    +        from hermes_cli.config import apply_terminal_config_to_env
    +
    +        # env=None targets os.environ inside the helper; override=False keeps
    +        # any already-set TERMINAL_* values (e.g. from .env) authoritative.
    +        apply_terminal_config_to_env(env=None, override=False)
    +    except Exception:
    +        # Never let a config problem take the terminal tool down — the
    +        # historical local default still applies.
    +        logger.debug("terminal config → env fallback bridge failed", exc_info=True)
    +
    +
     def _get_env_config() -> Dict[str, Any]:
         """Get terminal environment configuration from environment variables."""
         # Default image with Python and Node.js for maximum compatibility
         default_image = "nikolaik/python-nodejs:python3.11-nodejs20"
    +    _ensure_terminal_env_bridged()
         env_type = os.getenv("TERMINAL_ENV", "local")
         
         mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"}
    ```
  - `tui_gateway/server.py`

    ```diff
    @@ -1542,8 +1542,25 @@ def _terminal_task_cwd(session: dict | None) -> str:
         point at nonsense.  Non-local terminal backends are different: their cwd is
         inside the target environment, so an SSH path like /home/user/workspace may
         not exist on the local macOS host but is still the correct execution cwd.
    +
    +    When ``TERMINAL_ENV`` is unset (dashboard/TUI process) the config's
    +    ``terminal.backend`` is consulted as a fallback so the non-local cwd
    +    resolution path is taken even when the dashboard entrypoint did not call
    +    ``apply_terminal_config_to_env`` on its own ``os.environ``.
         """
         backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
    +    if not backend or backend == "local":
    +        # Fall back to config when TERMINAL_ENV is unset (dashboard/TUI process
    +        # never calls apply_terminal_config_to_env on os.environ).
    +        try:
    +            terminal_cfg = _load_cfg().get("terminal", {})
    +            if isinstance(terminal_cfg, dict):
    +                cfg_backend = str(terminal_cfg.get("backend") or "").strip().lower()
    +                if cfg_backend and cfg_backend != "local":
    +                    backend = cfg_backend
    +        except Exception:
    +            pass
    +
         if backend and backend != "local":
             raw = os.environ.get("TERMINAL_CWD", "").strip()
             if not raw:
    ```
- **#54526: fix(tui): fall back to config terminal.backend when TERMINAL_ENV is unset in dashboard/TUI process**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#65760`
- **#63332: fix(serve): bridge terminal.backend config to TERMINAL_ENV in start_server()**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#63613: fix(serve): bridge terminal.backend config to TERMINAL_ENV in start_server**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#63950: fix(gateway): prevent goal continuation loop on agent failure (#63180)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#63180`, `#63150`, `#63953`, `#63141`, `#63150`
- **#63953: fix(serve): bridge terminal.backend config to TERMINAL_ENV in start_server (#63141)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#64096: fix(serve): bridge terminal.backend config to TERMINAL_ENV in start_server()**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#63150`, `#63141`, `#63150`, `#63332`, `#63613`, `#63953`

## Fix status

- Merged to main in PR #65760 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-16`.
- Subsystem: `dashboard`
