{"repo":"NousResearch/hermes-agent","public_id":"19214","display_label":"CLI local backend ignores terminal.cwd config","summary":"These issues and fixes concern the CLI/TUI on the local backend always using the launch directory instead of respecting (or deliberately overriding) the terminal.cwd configuration. There is some tension between whether terminal.cwd should be honored or treated as a foot-gun to avoid.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19334,"merged_at":"2026-05-04T06:06:19+00:00"},"resolution":{"state":"resolved","by":"pr-19242"},"freshness":{"state":"verified","last_verified_at":"2026-05-04T06:06:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":19214,"pr_number":19334,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-04T06:06:19+00:00"}],"members":[{"kind":"issue","number":19214,"title":"terminal.cwd config is a foot-gun: CLI/TUI should always use launch directory","url":null,"state":"closed"},{"kind":"pr","number":19218,"title":"fix(cli): prefer launch cwd for local sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10817","number":10817}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19242,"title":"fix(cli): CLI/TUI on local backend always uses launch directory, ignores terminal.cwd","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true},{"kind":"pr","number":19334,"title":"fix(cli): local backend CLI always uses launch directory","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_cli/config.py","hermes_cli/setup.py","tests/cli/test_cwd_env_respect.py","website/docs/reference/environment-variables.md","website/docs/user-guide/configuration.md"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -459,32 +459,19 @@ def load_cli_config() -> Dict[str, Any]:\n     if \"backend\" in terminal_config:\n         terminal_config[\"env_type\"] = terminal_config[\"backend\"]\n     \n-    # Handle special cwd values: \".\" or \"auto\" means use current working directory.\n-    # Only resolve to the host's CWD for the local backend where the host\n-    # filesystem is directly accessible.  For ALL remote/container backends\n-    # (ssh, docker, modal, singularity), the host path doesn't exist on the\n-    # target -- remove the key so terminal_tool.py uses its per-backend default.\n-    #\n-    # GUARD: If TERMINAL_CWD is already set to a real absolute path (by the\n-    # gateway's config bridge earlier in the process), don't clobber it.\n-    # This prevents a lazy import of cli.py during gateway runtime from\n-    # rewriting TERMINAL_CWD to the service's working directory.\n-    # See issue #10817.\n+    # CWD resolution for CLI/TUI. The gateway has its own config bridge in\n+    # gateway/run.py but may lazily import cli.py (triggering this code).\n+    # Local backend: always os.getcwd(). Use `cd /dir && hermes` to control it.\n+    # Non-local with placeholder: pop so terminal_tool uses its per-backend default.\n+    # Non-local with explicit path: keep as-is.\n     _CWD_PLACEHOLDERS = (\".\", \"auto\", \"cwd\")\n-    if terminal_config.get(\"cwd\") in _CWD_PLACEHOLDERS:\n-        _existing_cwd = os.environ.get(\"TERMINAL_CWD\", \"\")\n-        if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):\n-            # Gateway (or earlier startup) already resolved a real path — keep it\n-            terminal_config[\"cwd\"] = _existing_cwd\n-            defaults[\"terminal\"][\"cwd\"] = _existing_cwd\n-        else:\n-            effective_backend = terminal_config.get(\"env_type\", \"local\")\n-            if effective_backend == \"local\":\n-                terminal_config[\"cwd\"] = os.getcwd()\n-                defaults[\"terminal\"][\"cwd\"] = terminal_config[\"cwd\"]\n-            else:\n-                # Remove so TERMINAL_CWD stays unset → tool picks backend default\n-                terminal_config.pop(\"cwd\", None)\n+    effective_backend = terminal_config.get(\"env_type\", \"local\")\n+\n+    if effective_backend == \"local\":\n+        terminal_config[\"cwd\"] = os.getcwd()\n+        defaults[\"terminal\"][\"cwd\"] = terminal_config[\"cwd\"]\n+    elif terminal_config.get(\"cwd\") in _CWD_PLACEHOLDERS:\n+        terminal_config.pop(\"cwd\", None)\n     \n     env_mappings = {\n         \"env_type\": \"TERMINAL_ENV\",","@@ -517,13 +504,18 @@ def load_cli_config() -> Dict[str, Any]:\n         \"sudo_password\": \"SUDO_PASSWORD\",\n     }\n     \n-    # Apply config values to env vars so terminal_tool picks them up.\n-    # If the config file explicitly has a [terminal] section, those values are\n-    # authoritative and override any .env settings.  When using defaults only\n-    # (no config file or no terminal section), don't overwrite env vars that\n-    # were already set by .env -- the user's .env is the fallback source.\n+    # Bridge config → env vars for terminal_tool. TERMINAL_CWD is force-exported\n+    # UNLESS we're inside a gateway process (detected by _HERMES_GATEWAY marker)\n+    # where it was already set correctly by gateway/run.py's config bridge.\n+    _is_gateway = os.environ.get(\"_HERMES_GATEWAY\") == \"1\"\n     for config_key, env_var in env_mappings.items():\n         if config_key in terminal_config:\n+            if env_var == \"TERMINAL_CWD\":\n+                if _is_gateway:\n+                    continue\n+                # CLI: always export (overrides stale .env or inherited values)\n+                os.environ[env_var] = str(terminal_config[config_key])\n+                continue\n             if _file_has_terminal_config or env_var not in os.environ:\n                 val = terminal_config[config_key]\n                 if isinstance(val, list):"]},{"file":"gateway/run.py","hunks":["@@ -293,6 +293,10 @@ def _restart_notification_pending() -> bool:\n     return (_hermes_home / \".restart_notify.json\").exists()\n \n \n+# Mark this process as a gateway so cli.py's module-level load_cli_config()\n+# knows not to clobber TERMINAL_CWD if lazily imported.\n+os.environ[\"_HERMES_GATEWAY\"] = \"1\"\n+\n _ensure_ssl_certs()\n \n # Add parent directory to path"]},{"file":"hermes_cli/config.py","hunks":["@@ -4675,7 +4675,9 @@ def set_config_value(key: str, value: str):\n         \"terminal.vercel_runtime\": \"TERMINAL_VERCEL_RUNTIME\",\n         \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n         \"terminal.docker_run_as_host_user\": \"TERMINAL_DOCKER_RUN_AS_HOST_USER\",\n-        \"terminal.cwd\": \"TERMINAL_CWD\",\n+        # terminal.cwd intentionally excluded — CLI resolves at runtime,\n+        # gateway bridges it in gateway/run.py. Persisting to .env causes\n+        # stale values to poison child processes.\n         \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n         \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n         \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\","]},{"file":"hermes_cli/setup.py","hunks":["@@ -1328,15 +1328,13 @@ def setup_terminal_backend(config: dict):\n         print_success(\"Terminal backend: Local\")\n         print_info(\"Commands run directly on this machine.\")\n \n-        # CWD for messaging\n+        # Gateway/cron working directory\n         print()\n-        print_info(\"Working directory for messaging sessions:\")\n-        print_info(\"  When using Hermes via Telegram/Discord, this is where\")\n-        print_info(\n-            \"  the agent starts. CLI mode always starts in the current directory.\"\n-        )\n+        print_info(\"Gateway working directory:\")\n+        print_info(\"  Used by Telegram/Discord/cron sessions.\")\n+        print_info(\"  CLI/TUI always uses your launch directory instead.\")\n         current_cwd = cfg_get(config, \"terminal\", \"cwd\", default=\"\")\n-        cwd = prompt(\"  Messaging working directory\", current_cwd or str(Path.home()))\n+        cwd = prompt(\"  Gateway working directory\", current_cwd or str(Path.home()))\n         if cwd:\n             config[\"terminal\"][\"cwd\"] = cwd\n "]},{"file":"tests/cli/test_cwd_env_respect.py","hunks":["@@ -1,107 +1,101 @@\n-\"\"\"Tests that load_cli_config() guards against lazy-import TERMINAL_CWD clobbering.\n+\"\"\"Tests for CLI/TUI CWD resolution in load_cli_config().\n \n-When the gateway resolves TERMINAL_CWD at startup and cli.py is later\n-imported lazily (via delegate_tool → CLI_CONFIG), load_cli_config() must\n-not overwrite the already-resolved value with os.getcwd().\n-\n-config.yaml terminal.cwd is the canonical source of truth.\n-.env TERMINAL_CWD and MESSAGING_CWD are deprecated.\n-See issue #10817.\n+Rules:\n+- Local backend CLI/TUI: always os.getcwd(), ignoring config and inherited env.\n+- Non-local with placeholder: pop cwd for backend default.\n+- Non-local with explicit path: keep as-is.\n \"\"\"\n \n import os\n import pytest\n \n-\n-# The sentinel values that mean \"resolve at runtime\"\n _CWD_PLACEHOLDERS = (\".\", \"auto\", \"cwd\")\n \n \n-def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict):\n-    \"\"\"Simulate the CWD resolution logic from load_cli_config().\n+def _resolve_cwd(terminal_config: dict, defaults: dict, env: dict):\n+    \"\"\"Mirror the CWD resolution logic from cli.py load_cli_config().\"\"\"\n+    effective_backend = terminal_config.get(\"env_type\", \"local\")\n \n-    This mirrors the code in cli.py that checks for a pre-resolved\n-    TERMINAL_CWD before falling back to os.getcwd().\n-    \"\"\"\n-    if terminal_config.get(\"cwd\") in _CWD_PLACEHOLDERS:\n-        _existing_cwd = env.get(\"TERMINAL_CWD\", \"\")\n-        if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):\n-            terminal_config[\"cwd\"] = _existing_cwd\n-            defaults[\"terminal\"][\"cwd\"] = _existing_cwd\n-        else:\n-            effective_backend = terminal_config.get(\"env_type\", \"local\")\n-            if effective_backend == \"local\":\n-                terminal_config[\"cwd\"] = \"/fake/getcwd\"  # stand-in for os.getcwd()\n-                defaults[\"terminal\"][\"cwd\"] = terminal_config[\"cwd\"]\n-            else:\n-                terminal_config.pop(\"cwd\", None)\n-\n-    # Simulate the bridging loop: write terminal_config[\"cwd\"] to env\n-    _file_has_terminal = defaults.get(\"_file_has_terminal\", False)\n+    if effective_backend == \"local\":\n+        terminal_config[\"cwd\"] = \"/fake/getcwd\"\n+        defaults[\"terminal\"][\"cwd\"] = terminal_config[\"cwd\"]\n+    elif terminal_config.get(\"cwd\") in _CWD_PLACEHOLDERS:\n+        terminal_config.pop(\"cwd\", None)\n+\n+    # Bridge: TERMINAL_CWD always exported in CLI, skipped in gateway\n+    _is_gateway = env.get(\"_HERMES_GATEWAY\") == \"1\"\n     if \"cwd\" in terminal_config:\n-        if _file_has_terminal or \"TERMINAL_CWD\" not in env:\n+        if _is_gateway:\n+            pass  # don't touch env\n+        else:\n             env[\"TERMINAL_CWD\"] = str(terminal_config[\"cwd\"])\n \n     return env.get(\"TERMINAL_CWD\", \"\")\n \n \n-class TestLazyImportGuard:\n-    \"\"\"TERMINAL_CWD resolved by gateway must survive a lazy cli.py import.\"\"\"\n-\n-    def test_gateway_resolved_cwd_survives(self):\n-        \"\"\"Gateway set TERMINAL_CWD → lazy cli import must not clobber.\"\"\"\n-        env = {\"TERMINAL_CWD\": \"/home/user/workspace\"}\n-        terminal_config = {\"cwd\": \".\", \"env_type\": \"local\"}\n-        defaults = {\"terminal\": {\"cwd\": \".\"}, \"_file_has_terminal\": False}\n+class TestLocalBackendCli:\n+    \"\"\"Local backend always uses os.getcwd().\"\"\"\n \n-        result = _resolve_terminal_cwd(terminal_config, defaults, env)\n-        assert result == \"/home/user/workspace\"\n-\n-    def test_gateway_resolved_cwd_survives_with_file_terminal(self):\n-        \"\"\"Even when config.yaml has a terminal: section, resolved CWD survives.\"\"\"\n-        env = {\"TERMINAL_CWD\": \"/home/user/workspace\"}\n-        terminal_config = {\"cwd\": \".\", \"env_type\": \"local\"}\n-        defaults = {\"terminal\": {\"cwd\": \".\"}, \"_file_has_terminal\": True}\n+    def test_explicit_config_ignored(self):\n+        env = {}\n+        tc = {\"cwd\": \"/explicit/path\", \"env_type\": \"local\"}\n+        d = {\"terminal\": {\"cwd\": \"/explicit/path\"}}\n+        assert _resolve_cwd(tc, d, env) == \"/fake/getcwd\"\n \n-        result = _resolve_terminal_cwd(terminal_config, defaults, env)\n-        assert result == \"/home/user/workspace\"\n+    def test_inherited_env_overwritten(self):\n+        env = {\"TERMINAL_CWD\": \"/parent/hermes\"}\n+        tc = {\"cwd\": \"/home/user\", \"env_type\": \"local\"}\n+        d = {\"terminal\": {\"cwd\": \"/home/user\"}}\n+        assert _resolve_cwd(tc, d, env) == \"/fake/getcwd\"\n \n+    def test_placeholder_resolved(self):\n+        env = {}\n+        tc = {\"cwd\": \".\"}\n+        d = {\"terminal\": {\"cwd\": \".\"}}\n+        assert _resolve_cwd(tc, d, env) == \"/fake/getcwd\"\n \n-class TestConfigCwdResolution:\n-    \"\"\"config.yaml terminal.cwd is the canonical source of truth.\"\"\"\n+    def test_env_and_no_config_file(self):\n+        env = {\"TERMINAL_CWD\": \"/stale/value\"}\n+        tc = {\"cwd\": \".\", \"env_type\": \"local\"}\n+        d = {\"terminal\": {\"cwd\": \".\"}}\n+        assert _resolve_cwd(tc, d, env) == \"/fake/getcwd\"\n \n-    def test_explicit_config_cwd_wins(self):\n-        \"\"\"terminal.cwd: /explicit/path always wins.\"\"\"\n-        env = {\"TERMINAL_CWD\": \"/old/gateway/value\"}\n-        terminal_config = {\"cwd\": \"/explicit/path\"}\n-        defaults = {\"terminal\": {\"cwd\": \"/explicit/path\"}, \"_file_has_terminal\": True}\n \n-        result = _resolve_terminal_cwd(terminal_config, defaults, env)\n-        assert result == \"/explicit/path\"\n+class TestNonLocalBackends:\n+    \"\"\"Non-local backends use config or per-backend defaults.\"\"\"\n \n-    def test_dot_cwd_resolves_to_getcwd_when_no_prior(self):\n-        \"\"\"With no pre-set TERMINAL_CWD, \".\" resolves to os.getcwd().\"\"\"\n+    def test_placeholder_popped(self):\n         env = {}\n-        terminal_config = {\"cwd\": \".\"}\n-        defaults = {\"terminal\": {\"cwd\": \".\"}, \"_file_has_terminal\": False}\n-\n-        result = _resolve_terminal_cwd(terminal_config, defaults, env)\n-        assert result == \"/fake/getcwd\"\n+        tc = {\"cwd\": \".\", \"env_type\": \"docker\"}\n+        d = {\"terminal\": {\"cwd\": \".\"}}\n+        assert _resolve_cwd(tc, d, env) == \"\"\n \n-    def test_remote_backend_pops_cwd(self):\n-        \"\"\"Remote backend + placeholder cwd → popped for backend default.\"\"\"\n+    def test_explicit_path_kept(self):\n         env = {}\n-        terminal_config = {\"cwd\": \".\", \"env_type\": \"docker\"}\n-        defaults = {\"terminal\": {\"cwd\": \".\"}, \"_file_has_terminal\": False}\n-\n-        result = _resolve_terminal_cwd(terminal_config, defaults, env)\n-        assert result == \"\"  # cwd popped, no env var set\n+        tc = {\"cwd\": \"/srv/app\", \"env_type\": \"ssh\"}\n+        d = {\"terminal\": {\"cwd\": \"/srv/app\"}}\n+        assert _resolve_cwd(tc, d, env) == \"/srv/app\"\n \n-    def test_remote_backend_with_prior_cwd_preserves(self):\n-        \"\"\"Remote backend + pre-resolved TERMINAL_CWD → adopted.\"\"\"\n-        env = {\"TERMINAL_CWD\": \"/project\"}\n-        terminal_config = {\"cwd\": \".\", \"env_type\": \"docker\"}\n-        defaults = {\"terminal\": {\"cwd\": \".\"}, \"_file_has_terminal\": False}\n-\n-        result = _resolve_terminal_cwd(terminal_config, defaults, env)\n-        assert result == \"/project\"\n+    def test_auto_placeholder_popped(self):\n+        env = {}\n+        tc = {\"cwd\": \"auto\", \"env_type\": \"modal\"}\n+        d = {\"terminal\": {\"cwd\": \"auto\"}}\n+        assert _resolve_cwd(tc, d, env) == \"\"\n+\n+\n+class TestGatewayLazyImport:\n+    \"\"\"Gateway lazy import of cli.py must not clobber TERMINAL_CWD.\"\"\"\n+\n+    def test_gateway_cwd_preserved(self):\n+        env = {\"_HERMES_GATEWAY\": \"1\", \"TERMINAL_CWD\": \"/home/user/project\"}\n+        tc = {\"cwd\": \"/home/user\", \"env_type\": \"local\"}\n+        d = {\"terminal\": {\"cwd\": \"/home/user\"}}\n+        result = _resolve_cwd(tc, d, env)\n+        assert result == \"/home/user/project\"\n+\n+    def test_cli_overwrites_stale_env(self):\n+        env = {\"TERMINAL_CWD\": \"/stale/from/dotenv\"}\n+        tc = {\"cwd\": \"/home/user\", \"env_type\": \"local\"}\n+        d = {\"terminal\": {\"cwd\": \"/home/user\"}}\n+        result = _resolve_cwd(tc, d, env)\n+        assert result == \"/fake/getcwd\""]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -184,7 +184,7 @@ These variables configure the [Tool Gateway](/docs/user-guide/features/tool-gate\n | `TERMINAL_VERCEL_RUNTIME` | Vercel Sandbox runtime (`node24`, `node22`, `python3.13`) |\n | `TERMINAL_TIMEOUT` | Command timeout in seconds |\n | `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds |\n-| `TERMINAL_CWD` | Working directory for all terminal sessions |\n+| `TERMINAL_CWD` | Working directory for terminal sessions (gateway/cron only; CLI uses launch dir) |\n | `SUDO_PASSWORD` | Enable sudo without interactive prompt |\n \n For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETIME_SECONDS` controls when Hermes cleans up an idle terminal session, and later resumes may recreate the sandbox rather than keep the same live processes running."]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -88,7 +88,7 @@ Hermes supports seven terminal backends. Each determines where the agent's shell\n ```yaml\n terminal:\n   backend: local    # local | docker | ssh | modal | daytona | vercel_sandbox | singularity\n-  cwd: \".\"          # Working directory (\".\" = current dir for local, \"/root\" for containers)\n+  cwd: \".\"          # Gateway/cron working directory (CLI always uses launch dir)\n   timeout: 180      # Per-command timeout in seconds\n   env_passthrough: []  # Env var names to forward to sandboxed execution (terminal + execute_code)\n   singularity_image: \"docker://nikolaik/python-nodejs:python3.11-nodejs20\"  # Container image for Singularity backend\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}