fix(cli): CLI/TUI on local backend always uses launch directory, ignores terminal.cwd (#19242)

CLI/TUI sessions on the local backend now unconditionally use
os.getcwd() as the working directory. The terminal.cwd config value is
only consumed by gateway/cron/delegation modes (where there's no shell
to cd from).

Previously, 'hermes setup' would write an absolute path (e.g. $HOME)
into terminal.cwd which then pinned the CLI to that directory regardless
of where the user launched hermes from. This was a silent foot-gun —
the user's 'cd' was being ignored.

Changes:

1. cli.py: Restructured CWD resolution — if TERMINAL_CWD is not already
   set by the gateway, and the backend is local, always use os.getcwd().
   Config terminal.cwd is irrelevant for interactive CLI/TUI sessions.

2. setup.py: Moved the cwd prompt from setup_terminal_backend() to
   setup_gateway(). It now only appears when configuring messaging
   platforms and is labeled 'Gateway working directory'.

3. Tests: Rewrote test_cwd_env_respect.py to validate the new behavior:
   explicit config paths are ignored for CLI, gateway pre-set values are
   preserved, non-local backends keep their config paths.

4. Docs: Updated configuration.md, profiles.md, and
   environment-variables.md to clarify that terminal.cwd only affects
   gateway/cron mode on local backend.

Closes #19214
This commit is contained in:
Siddharth Balyan
2026-05-04 00:14:36 +05:30
committed by GitHub
parent b8ae8cc801
commit 9eaddfafa3
6 changed files with 122 additions and 72 deletions

48
cli.py
View File

@@ -459,32 +459,30 @@ def load_cli_config() -> Dict[str, Any]:
if "backend" in terminal_config: if "backend" in terminal_config:
terminal_config["env_type"] = terminal_config["backend"] terminal_config["env_type"] = terminal_config["backend"]
# Handle special cwd values: "." or "auto" means use current working directory. # CWD resolution: CLI/TUI on local backend always uses os.getcwd();
# Only resolve to the host's CWD for the local backend where the host # gateway/cron uses terminal.cwd from config. Detection: gateway's config
# filesystem is directly accessible. For ALL remote/container backends # bridge (gateway/run.py) sets TERMINAL_CWD before this runs.
# (ssh, docker, modal, singularity), the host path doesn't exist on the # See #19214, #4672, #10225, #10817.
# target -- remove the key so terminal_tool.py uses its per-backend default.
#
# GUARD: If TERMINAL_CWD is already set to a real absolute path (by the
# gateway's config bridge earlier in the process), don't clobber it.
# This prevents a lazy import of cli.py during gateway runtime from
# rewriting TERMINAL_CWD to the service's working directory.
# See issue #10817.
_CWD_PLACEHOLDERS = (".", "auto", "cwd") _CWD_PLACEHOLDERS = (".", "auto", "cwd")
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS: _existing_cwd = os.environ.get("TERMINAL_CWD", "")
_existing_cwd = os.environ.get("TERMINAL_CWD", "") _is_gateway_import = (
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd): _existing_cwd
# Gateway (or earlier startup) already resolved a real path — keep it and _existing_cwd not in _CWD_PLACEHOLDERS
terminal_config["cwd"] = _existing_cwd and os.path.isabs(_existing_cwd)
defaults["terminal"]["cwd"] = _existing_cwd )
else: effective_backend = terminal_config.get("env_type", "local")
effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local": if _is_gateway_import:
terminal_config["cwd"] = os.getcwd() terminal_config["cwd"] = _existing_cwd
defaults["terminal"]["cwd"] = terminal_config["cwd"] defaults["terminal"]["cwd"] = _existing_cwd
else: elif effective_backend == "local":
# Remove so TERMINAL_CWD stays unset → tool picks backend default # CLI/TUI: user's `cd` is the config — ignore terminal.cwd.
terminal_config.pop("cwd", None) terminal_config["cwd"] = os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
# Non-local backend with placeholder — let terminal_tool use its default.
terminal_config.pop("cwd", None)
# else: non-local backend with explicit path — keep as-is
env_mappings = { env_mappings = {
"env_type": "TERMINAL_ENV", "env_type": "TERMINAL_ENV",

View File

@@ -1327,18 +1327,7 @@ def setup_terminal_backend(config: dict):
if selected_backend == "local": if selected_backend == "local":
print_success("Terminal backend: Local") print_success("Terminal backend: Local")
print_info("Commands run directly on this machine.") print_info("Commands run directly on this machine.")
print_info(" CLI/TUI always uses your launch directory (wherever you run 'hermes').")
# CWD for messaging
print()
print_info("Working directory for messaging sessions:")
print_info(" When using Hermes via Telegram/Discord, this is where")
print_info(
" the agent starts. CLI mode always starts in the current directory."
)
current_cwd = cfg_get(config, "terminal", "cwd", default="")
cwd = prompt(" Messaging working directory", current_cwd or str(Path.home()))
if cwd:
config["terminal"]["cwd"] = cwd
# Sudo support # Sudo support
print() print()
@@ -2390,6 +2379,20 @@ def setup_gateway(config: dict):
print_info("" * 50) print_info("" * 50)
print_success("Messaging platforms configured!") print_success("Messaging platforms configured!")
# Gateway working directory — where the agent starts when you chat
# via Telegram/Discord/etc. CLI/TUI ignores this (uses launch dir).
print()
print_info("Gateway working directory:")
print_info(" When using Hermes via messaging platforms, this is where")
print_info(" the agent's terminal commands start.")
print_info(" (CLI/TUI always uses wherever you launched 'hermes' from.)")
current_cwd = cfg_get(config, "terminal", "cwd", default="")
if current_cwd in (".", "auto", "cwd", ""):
current_cwd = ""
cwd = prompt(" Gateway working directory", current_cwd or str(Path.home()))
if cwd:
config.setdefault("terminal", {})["cwd"] = cwd
# Check if any home channels are missing # Check if any home channels are missing
missing_home = [] missing_home = []
if get_env_value("TELEGRAM_BOT_TOKEN") and not get_env_value( if get_env_value("TELEGRAM_BOT_TOKEN") and not get_env_value(

View File

@@ -1,12 +1,12 @@
"""Tests that load_cli_config() guards against lazy-import TERMINAL_CWD clobbering. """Tests that load_cli_config() CWD resolution works correctly.
When the gateway resolves TERMINAL_CWD at startup and cli.py is later The rule:
imported lazily (via delegate_tool → CLI_CONFIG), load_cli_config() must - CLI/TUI on local backend: ALWAYS use os.getcwd() (config ignored).
not overwrite the already-resolved value with os.getcwd(). - Gateway (TERMINAL_CWD pre-set to absolute path): respect it.
- Non-local backends with placeholder: pop cwd for backend default.
- Non-local backends with explicit path: keep it.
config.yaml terminal.cwd is the canonical source of truth. See issues #19214, #4672, #10225, #10817.
.env TERMINAL_CWD and MESSAGING_CWD are deprecated.
See issue #10817.
""" """
import os import os
@@ -20,21 +20,29 @@ _CWD_PLACEHOLDERS = (".", "auto", "cwd")
def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict): def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict):
"""Simulate the CWD resolution logic from load_cli_config(). """Simulate the CWD resolution logic from load_cli_config().
This mirrors the code in cli.py that checks for a pre-resolved This mirrors the code in cli.py that handles the CWD resolution
TERMINAL_CWD before falling back to os.getcwd(). based on mode (CLI vs gateway) and backend type.
""" """
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS: _existing_cwd = env.get("TERMINAL_CWD", "")
_existing_cwd = env.get("TERMINAL_CWD", "") _is_gateway_import = (
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd): _existing_cwd
terminal_config["cwd"] = _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS
defaults["terminal"]["cwd"] = _existing_cwd and os.path.isabs(_existing_cwd)
else: )
effective_backend = terminal_config.get("env_type", "local") effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local":
terminal_config["cwd"] = "/fake/getcwd" # stand-in for os.getcwd() if _is_gateway_import:
defaults["terminal"]["cwd"] = terminal_config["cwd"] # Gateway already resolved a real path — keep it.
else: terminal_config["cwd"] = _existing_cwd
terminal_config.pop("cwd", None) defaults["terminal"]["cwd"] = _existing_cwd
elif effective_backend == "local":
# CLI/TUI on local backend: always use launch directory.
terminal_config["cwd"] = "/fake/getcwd" # stand-in for os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
# Non-local backend with placeholder — pop for backend default.
terminal_config.pop("cwd", None)
# else: non-local backend with explicit path — keep as-is
# Simulate the bridging loop: write terminal_config["cwd"] to env # Simulate the bridging loop: write terminal_config["cwd"] to env
_file_has_terminal = defaults.get("_file_has_terminal", False) _file_has_terminal = defaults.get("_file_has_terminal", False)
@@ -66,18 +74,36 @@ class TestLazyImportGuard:
result = _resolve_terminal_cwd(terminal_config, defaults, env) result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/home/user/workspace" assert result == "/home/user/workspace"
def test_gateway_resolved_cwd_survives_even_with_explicit_config(self):
"""Gateway pre-set TERMINAL_CWD wins even when config has explicit path.
class TestConfigCwdResolution: This is the key scenario: config.yaml has terminal.cwd: /home/user
"""config.yaml terminal.cwd is the canonical source of truth.""" (from hermes setup), but the gateway already resolved TERMINAL_CWD.
The gateway's value must win.
"""
env = {"TERMINAL_CWD": "/home/user/workspace"}
terminal_config = {"cwd": "/home/user", "env_type": "local"}
defaults = {"terminal": {"cwd": "/home/user"}, "_file_has_terminal": True}
def test_explicit_config_cwd_wins(self): result = _resolve_terminal_cwd(terminal_config, defaults, env)
"""terminal.cwd: /explicit/path always wins.""" assert result == "/home/user/workspace"
env = {"TERMINAL_CWD": "/old/gateway/value"}
terminal_config = {"cwd": "/explicit/path"}
class TestCliAlwaysUsesGetcwd:
"""CLI/TUI on local backend always uses os.getcwd(), ignoring config."""
def test_explicit_config_cwd_ignored_on_local_cli(self):
"""terminal.cwd: /explicit/path is IGNORED for CLI on local backend.
This is the #19214 fix — 'hermes setup' may have written an absolute
path, but CLI always uses os.getcwd() (the user's launch directory).
"""
env = {} # No pre-set TERMINAL_CWD = CLI mode
terminal_config = {"cwd": "/explicit/path", "env_type": "local"}
defaults = {"terminal": {"cwd": "/explicit/path"}, "_file_has_terminal": True} defaults = {"terminal": {"cwd": "/explicit/path"}, "_file_has_terminal": True}
result = _resolve_terminal_cwd(terminal_config, defaults, env) result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/explicit/path" assert result == "/fake/getcwd" # os.getcwd(), NOT /explicit/path
def test_dot_cwd_resolves_to_getcwd_when_no_prior(self): def test_dot_cwd_resolves_to_getcwd_when_no_prior(self):
"""With no pre-set TERMINAL_CWD, "." resolves to os.getcwd().""" """With no pre-set TERMINAL_CWD, "." resolves to os.getcwd()."""
@@ -88,7 +114,20 @@ class TestConfigCwdResolution:
result = _resolve_terminal_cwd(terminal_config, defaults, env) result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/fake/getcwd" assert result == "/fake/getcwd"
def test_remote_backend_pops_cwd(self): def test_home_dir_config_ignored_on_local_cli(self):
"""terminal.cwd: ~ (home dir from setup) is ignored for CLI."""
env = {}
terminal_config = {"cwd": "/home/daimon", "env_type": "local"}
defaults = {"terminal": {"cwd": "/home/daimon"}, "_file_has_terminal": True}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/fake/getcwd"
class TestNonLocalBackends:
"""Non-local backends use config or per-backend defaults."""
def test_remote_backend_pops_placeholder_cwd(self):
"""Remote backend + placeholder cwd → popped for backend default.""" """Remote backend + placeholder cwd → popped for backend default."""
env = {} env = {}
terminal_config = {"cwd": ".", "env_type": "docker"} terminal_config = {"cwd": ".", "env_type": "docker"}
@@ -97,6 +136,15 @@ class TestConfigCwdResolution:
result = _resolve_terminal_cwd(terminal_config, defaults, env) result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "" # cwd popped, no env var set assert result == "" # cwd popped, no env var set
def test_remote_backend_keeps_explicit_path(self):
"""Remote backend + explicit path → kept (e.g. SSH cwd: /srv/app)."""
env = {}
terminal_config = {"cwd": "/srv/myproject", "env_type": "ssh"}
defaults = {"terminal": {"cwd": "/srv/myproject"}, "_file_has_terminal": True}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/srv/myproject"
def test_remote_backend_with_prior_cwd_preserves(self): def test_remote_backend_with_prior_cwd_preserves(self):
"""Remote backend + pre-resolved TERMINAL_CWD → adopted.""" """Remote backend + pre-resolved TERMINAL_CWD → adopted."""
env = {"TERMINAL_CWD": "/project"} env = {"TERMINAL_CWD": "/project"}

View File

@@ -184,7 +184,7 @@ These variables configure the [Tool Gateway](/docs/user-guide/features/tool-gate
| `TERMINAL_VERCEL_RUNTIME` | Vercel Sandbox runtime (`node24`, `node22`, `python3.13`) | | `TERMINAL_VERCEL_RUNTIME` | Vercel Sandbox runtime (`node24`, `node22`, `python3.13`) |
| `TERMINAL_TIMEOUT` | Command timeout in seconds | | `TERMINAL_TIMEOUT` | Command timeout in seconds |
| `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds | | `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds |
| `TERMINAL_CWD` | Working directory for all terminal sessions | | `TERMINAL_CWD` | Working directory for gateway/cron terminal sessions (CLI/TUI on local backend ignores this — always uses launch directory) |
| `SUDO_PASSWORD` | Enable sudo without interactive prompt | | `SUDO_PASSWORD` | Enable sudo without interactive prompt |
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. 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.

View File

@@ -88,7 +88,7 @@ Hermes supports seven terminal backends. Each determines where the agent's shell
```yaml ```yaml
terminal: terminal:
backend: local # local | docker | ssh | modal | daytona | vercel_sandbox | singularity backend: local # local | docker | ssh | modal | daytona | vercel_sandbox | singularity
cwd: "." # Working directory ("." = current dir for local, "/root" for containers) cwd: "." # Gateway/cron working directory. CLI/TUI on local backend always uses your launch directory.
timeout: 180 # Per-command timeout in seconds timeout: 180 # Per-command timeout in seconds
env_passthrough: [] # Env var names to forward to sandboxed execution (terminal + execute_code) env_passthrough: [] # Env var names to forward to sandboxed execution (terminal + execute_code)
singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Singularity backend singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Singularity backend

View File

@@ -109,12 +109,12 @@ The CLI always shows which profile is active:
Profiles are often confused with workspaces or sandboxes, but they are different things: Profiles are often confused with workspaces or sandboxes, but they are different things:
- A **profile** gives Hermes its own state directory: `config.yaml`, `.env`, `SOUL.md`, sessions, memory, logs, cron jobs, and gateway state. - A **profile** gives Hermes its own state directory: `config.yaml`, `.env`, `SOUL.md`, sessions, memory, logs, cron jobs, and gateway state.
- A **workspace** or **working directory** is where terminal commands start. That is controlled separately by `terminal.cwd`. - A **workspace** or **working directory** is where terminal commands start. For CLI/TUI on local backend, this is always your launch directory. For gateway mode, it's controlled by `terminal.cwd` in config.
- A **sandbox** is what limits filesystem access. Profiles do **not** sandbox the agent. - A **sandbox** is what limits filesystem access. Profiles do **not** sandbox the agent.
On the default `local` terminal backend, the agent still has the same filesystem access as your user account. A profile does not stop it from accessing folders outside the profile directory. On the default `local` terminal backend, the agent still has the same filesystem access as your user account. A profile does not stop it from accessing folders outside the profile directory.
If you want a profile to start in a specific project folder, set an explicit absolute `terminal.cwd` in that profile's `config.yaml`: If you want a profile's **gateway** to start in a specific project folder, set an explicit absolute `terminal.cwd` in that profile's `config.yaml`:
```yaml ```yaml
terminal: terminal:
@@ -122,13 +122,14 @@ terminal:
cwd: /absolute/path/to/project cwd: /absolute/path/to/project
``` ```
Using `cwd: "."` on the local backend means "the directory Hermes was launched from", not "the profile directory". :::note
This only affects gateway/cron mode. If you run `hermes -p myprofile` from CLI, the agent uses your shell's current directory regardless of `terminal.cwd`. The `terminal.cwd` config is for headless modes (gateway, cron) where there's no shell to `cd` from.
:::
Also note: Also note:
- `SOUL.md` can guide the model, but it does not enforce a workspace boundary. - `SOUL.md` can guide the model, but it does not enforce a workspace boundary.
- Changes to `SOUL.md` take effect cleanly on a new session. Existing sessions may still be using the old prompt state. - Changes to `SOUL.md` take effect cleanly on a new session. Existing sessions may still be using the old prompt state.
- Asking the model "what directory are you in?" is not a reliable isolation test. If you need a predictable starting directory for tools, set `terminal.cwd` explicitly.
## Running gateways ## Running gateways