diff --git a/cli.py b/cli.py index ef745ae67b..da917ae190 100644 --- a/cli.py +++ b/cli.py @@ -459,30 +459,32 @@ def load_cli_config() -> Dict[str, Any]: if "backend" in terminal_config: terminal_config["env_type"] = terminal_config["backend"] - # 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. + # 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_PLACEHOLDERS = (".", "auto", "cwd") - _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 + 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) env_mappings = { "env_type": "TERMINAL_ENV", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 88c297c1b4..31cb846012 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1327,7 +1327,18 @@ def setup_terminal_backend(config: dict): if selected_backend == "local": print_success("Terminal backend: Local") 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 print() @@ -2379,20 +2390,6 @@ 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 7cf592db0c..e9f3341d2a 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() CWD resolution works correctly. +"""Tests that load_cli_config() guards against lazy-import TERMINAL_CWD clobbering. -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. +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(). -See issues #19214, #4672, #10225, #10817. +config.yaml terminal.cwd is the canonical source of truth. +.env TERMINAL_CWD and MESSAGING_CWD are deprecated. +See issue #10817. """ import os @@ -20,29 +20,21 @@ _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 handles the CWD resolution - based on mode (CLI vs gateway) and backend type. + This mirrors the code in cli.py that checks for a pre-resolved + TERMINAL_CWD before falling back to os.getcwd(). """ - _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 + 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) # Simulate the bridging loop: write terminal_config["cwd"] to env _file_has_terminal = defaults.get("_file_has_terminal", False) @@ -74,36 +66,18 @@ 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. - 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} +class TestConfigCwdResolution: + """config.yaml terminal.cwd is the canonical source of truth.""" - 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"} + def test_explicit_config_cwd_wins(self): + """terminal.cwd: /explicit/path always wins.""" + env = {"TERMINAL_CWD": "/old/gateway/value"} + terminal_config = {"cwd": "/explicit/path"} defaults = {"terminal": {"cwd": "/explicit/path"}, "_file_has_terminal": True} result = _resolve_terminal_cwd(terminal_config, defaults, env) - assert result == "/fake/getcwd" # os.getcwd(), NOT /explicit/path + assert result == "/explicit/path" def test_dot_cwd_resolves_to_getcwd_when_no_prior(self): """With no pre-set TERMINAL_CWD, "." resolves to os.getcwd().""" @@ -114,20 +88,7 @@ class TestCliAlwaysUsesGetcwd: result = _resolve_terminal_cwd(terminal_config, defaults, env) assert result == "/fake/getcwd" - 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): + def test_remote_backend_pops_cwd(self): """Remote backend + placeholder cwd → popped for backend default.""" env = {} terminal_config = {"cwd": ".", "env_type": "docker"} @@ -136,15 +97,6 @@ class TestNonLocalBackends: 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 b0c7e73d44..955f460014 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 gateway/cron terminal sessions (CLI/TUI on local backend ignores this — always uses launch directory) | +| `TERMINAL_CWD` | Working directory for all terminal sessions | | `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 57d1f60868..18c96b8b18 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: "." # Gateway/cron working directory. CLI/TUI on local backend always uses your launch directory. + cwd: "." # Working directory ("." = current dir for local, "/root" for containers) 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 6527fc1167..0dcc35db0a 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. For CLI/TUI on local backend, this is always your launch directory. For gateway mode, it's controlled by `terminal.cwd` in config. +- A **workspace** or **working directory** is where terminal commands start. That is controlled separately by `terminal.cwd`. - 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's **gateway** to start in a specific project folder, set an explicit absolute `terminal.cwd` in that profile's `config.yaml`: +If you want a profile to start in a specific project folder, set an explicit absolute `terminal.cwd` in that profile's `config.yaml`: ```yaml terminal: @@ -122,14 +122,13 @@ terminal: cwd: /absolute/path/to/project ``` -:::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. -::: +Using `cwd: "."` on the local backend means "the directory Hermes was launched from", not "the profile directory". 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