From 9eaddfafa30018b1d4eb3e5e72bbe2d242f8e50e Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Mon, 4 May 2026 00:14:36 +0530 Subject: [PATCH] fix(cli): CLI/TUI on local backend always uses launch directory, ignores terminal.cwd (#19242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli.py | 48 ++++---- hermes_cli/setup.py | 27 +++-- tests/cli/test_cwd_env_respect.py | 106 +++++++++++++----- .../docs/reference/environment-variables.md | 2 +- website/docs/user-guide/configuration.md | 2 +- website/docs/user-guide/profiles.md | 9 +- 6 files changed, 122 insertions(+), 72 deletions(-) diff --git a/cli.py b/cli.py index da917ae190..ef745ae67b 100644 --- a/cli.py +++ b/cli.py @@ -459,32 +459,30 @@ def load_cli_config() -> Dict[str, Any]: if "backend" in terminal_config: terminal_config["env_type"] = terminal_config["backend"] - # Handle special cwd values: "." or "auto" means use current working directory. - # Only resolve to the host's CWD for the local backend where the host - # filesystem is directly accessible. For ALL remote/container backends - # (ssh, docker, modal, singularity), the host path doesn't exist on the - # 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 resolution: CLI/TUI on local backend always uses os.getcwd(); + # gateway/cron uses terminal.cwd from config. Detection: gateway's config + # bridge (gateway/run.py) sets TERMINAL_CWD before this runs. + # See #19214, #4672, #10225, #10817. _CWD_PLACEHOLDERS = (".", "auto", "cwd") - if terminal_config.get("cwd") in _CWD_PLACEHOLDERS: - _existing_cwd = os.environ.get("TERMINAL_CWD", "") - if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd): - # Gateway (or earlier startup) already resolved a real path — keep it - terminal_config["cwd"] = _existing_cwd - defaults["terminal"]["cwd"] = _existing_cwd - else: - effective_backend = terminal_config.get("env_type", "local") - if effective_backend == "local": - terminal_config["cwd"] = os.getcwd() - defaults["terminal"]["cwd"] = terminal_config["cwd"] - else: - # Remove so TERMINAL_CWD stays unset → tool picks backend default - terminal_config.pop("cwd", None) + _existing_cwd = os.environ.get("TERMINAL_CWD", "") + _is_gateway_import = ( + _existing_cwd + and _existing_cwd not in _CWD_PLACEHOLDERS + and os.path.isabs(_existing_cwd) + ) + effective_backend = terminal_config.get("env_type", "local") + + if _is_gateway_import: + terminal_config["cwd"] = _existing_cwd + defaults["terminal"]["cwd"] = _existing_cwd + elif effective_backend == "local": + # CLI/TUI: user's `cd` is the config — ignore terminal.cwd. + 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_type": "TERMINAL_ENV", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 31cb846012..88c297c1b4 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1327,18 +1327,7 @@ def setup_terminal_backend(config: dict): if selected_backend == "local": print_success("Terminal backend: Local") print_info("Commands run directly on this machine.") - - # 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 + print_info(" CLI/TUI always uses your launch directory (wherever you run 'hermes').") # Sudo support print() @@ -2390,6 +2379,20 @@ def setup_gateway(config: dict): print_info("━" * 50) 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 missing_home = [] if get_env_value("TELEGRAM_BOT_TOKEN") and not get_env_value( diff --git a/tests/cli/test_cwd_env_respect.py b/tests/cli/test_cwd_env_respect.py index e9f3341d2a..7cf592db0c 100644 --- a/tests/cli/test_cwd_env_respect.py +++ b/tests/cli/test_cwd_env_respect.py @@ -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 -imported lazily (via delegate_tool → CLI_CONFIG), load_cli_config() must -not overwrite the already-resolved value with os.getcwd(). +The rule: +- CLI/TUI on local backend: ALWAYS use os.getcwd() (config ignored). +- 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. -.env TERMINAL_CWD and MESSAGING_CWD are deprecated. -See issue #10817. +See issues #19214, #4672, #10225, #10817. """ import os @@ -20,21 +20,29 @@ _CWD_PLACEHOLDERS = (".", "auto", "cwd") def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict): """Simulate the CWD resolution logic from load_cli_config(). - This mirrors the code in cli.py that checks for a pre-resolved - TERMINAL_CWD before falling back to os.getcwd(). + This mirrors the code in cli.py that handles the CWD resolution + based on mode (CLI vs gateway) and backend type. """ - if terminal_config.get("cwd") in _CWD_PLACEHOLDERS: - _existing_cwd = env.get("TERMINAL_CWD", "") - if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd): - terminal_config["cwd"] = _existing_cwd - defaults["terminal"]["cwd"] = _existing_cwd - else: - effective_backend = terminal_config.get("env_type", "local") - if effective_backend == "local": - terminal_config["cwd"] = "/fake/getcwd" # stand-in for os.getcwd() - defaults["terminal"]["cwd"] = terminal_config["cwd"] - else: - terminal_config.pop("cwd", None) + _existing_cwd = env.get("TERMINAL_CWD", "") + _is_gateway_import = ( + _existing_cwd + and _existing_cwd not in _CWD_PLACEHOLDERS + and os.path.isabs(_existing_cwd) + ) + effective_backend = terminal_config.get("env_type", "local") + + if _is_gateway_import: + # Gateway already resolved a real path — keep it. + terminal_config["cwd"] = _existing_cwd + 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 _file_has_terminal = defaults.get("_file_has_terminal", False) @@ -66,18 +74,36 @@ class TestLazyImportGuard: result = _resolve_terminal_cwd(terminal_config, defaults, env) 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: - """config.yaml terminal.cwd is the canonical source of truth.""" + This is the key scenario: config.yaml has terminal.cwd: /home/user + (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): - """terminal.cwd: /explicit/path always wins.""" - env = {"TERMINAL_CWD": "/old/gateway/value"} - terminal_config = {"cwd": "/explicit/path"} + result = _resolve_terminal_cwd(terminal_config, defaults, env) + assert result == "/home/user/workspace" + + +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} 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): """With no pre-set TERMINAL_CWD, "." resolves to os.getcwd().""" @@ -88,7 +114,20 @@ class TestConfigCwdResolution: result = _resolve_terminal_cwd(terminal_config, defaults, env) 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.""" env = {} terminal_config = {"cwd": ".", "env_type": "docker"} @@ -97,6 +136,15 @@ class TestConfigCwdResolution: result = _resolve_terminal_cwd(terminal_config, defaults, env) 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): """Remote backend + pre-resolved TERMINAL_CWD → adopted.""" env = {"TERMINAL_CWD": "/project"} diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 955f460014..b0c7e73d44 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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_TIMEOUT` | Command timeout 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 | 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. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 18c96b8b18..57d1f60868 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -88,7 +88,7 @@ Hermes supports seven terminal backends. Each determines where the agent's shell ```yaml terminal: 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 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 diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 0dcc35db0a..6527fc1167 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -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: - 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. 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 terminal: @@ -122,13 +122,14 @@ terminal: 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: - `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. -- 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