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:
48
cli.py
48
cli.py
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user