diff --git a/cli.py b/cli.py index da917ae190..472218271f 100644 --- a/cli.py +++ b/cli.py @@ -459,32 +459,19 @@ 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 for CLI/TUI. The gateway has its own config bridge in + # gateway/run.py but may lazily import cli.py (triggering this code). + # Local backend: always os.getcwd(). Use `cd /dir && hermes` to control it. + # Non-local with placeholder: pop so terminal_tool uses its per-backend default. + # Non-local with explicit path: keep as-is. _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) + effective_backend = terminal_config.get("env_type", "local") + + if effective_backend == "local": + terminal_config["cwd"] = os.getcwd() + defaults["terminal"]["cwd"] = terminal_config["cwd"] + elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS: + terminal_config.pop("cwd", None) env_mappings = { "env_type": "TERMINAL_ENV", @@ -517,13 +504,18 @@ def load_cli_config() -> Dict[str, Any]: "sudo_password": "SUDO_PASSWORD", } - # Apply config values to env vars so terminal_tool picks them up. - # If the config file explicitly has a [terminal] section, those values are - # authoritative and override any .env settings. When using defaults only - # (no config file or no terminal section), don't overwrite env vars that - # were already set by .env -- the user's .env is the fallback source. + # Bridge config → env vars for terminal_tool. TERMINAL_CWD is force-exported + # UNLESS we're inside a gateway process (detected by _HERMES_GATEWAY marker) + # where it was already set correctly by gateway/run.py's config bridge. + _is_gateway = os.environ.get("_HERMES_GATEWAY") == "1" for config_key, env_var in env_mappings.items(): if config_key in terminal_config: + if env_var == "TERMINAL_CWD": + if _is_gateway: + continue + # CLI: always export (overrides stale .env or inherited values) + os.environ[env_var] = str(terminal_config[config_key]) + continue if _file_has_terminal_config or env_var not in os.environ: val = terminal_config[config_key] if isinstance(val, list): diff --git a/gateway/run.py b/gateway/run.py index 7871686256..d4f2ba8d25 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -316,6 +316,10 @@ def _restart_notification_pending() -> bool: return (_hermes_home / ".restart_notify.json").exists() +# Mark this process as a gateway so cli.py's module-level load_cli_config() +# knows not to clobber TERMINAL_CWD if lazily imported. +os.environ["_HERMES_GATEWAY"] = "1" + _ensure_ssl_certs() # Add parent directory to path diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 25df4b3e2f..98317a9043 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4675,7 +4675,9 @@ def set_config_value(key: str, value: str): "terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME", "terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", - "terminal.cwd": "TERMINAL_CWD", + # terminal.cwd intentionally excluded — CLI resolves at runtime, + # gateway bridges it in gateway/run.py. Persisting to .env causes + # stale values to poison child processes. "terminal.timeout": "TERMINAL_TIMEOUT", "terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR", "terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 31cb846012..9ca29968fd 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1328,15 +1328,13 @@ def setup_terminal_backend(config: dict): print_success("Terminal backend: Local") print_info("Commands run directly on this machine.") - # CWD for messaging + # Gateway/cron working directory 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." - ) + print_info("Gateway working directory:") + print_info(" Used by Telegram/Discord/cron sessions.") + print_info(" CLI/TUI always uses your launch directory instead.") current_cwd = cfg_get(config, "terminal", "cwd", default="") - cwd = prompt(" Messaging working directory", current_cwd or str(Path.home())) + cwd = prompt(" Gateway working directory", current_cwd or str(Path.home())) if cwd: config["terminal"]["cwd"] = cwd diff --git a/tests/cli/test_cwd_env_respect.py b/tests/cli/test_cwd_env_respect.py index e9f3341d2a..04e62cc12f 100644 --- a/tests/cli/test_cwd_env_respect.py +++ b/tests/cli/test_cwd_env_respect.py @@ -1,107 +1,101 @@ -"""Tests that load_cli_config() guards against lazy-import TERMINAL_CWD clobbering. +"""Tests for CLI/TUI CWD resolution in load_cli_config(). -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(). - -config.yaml terminal.cwd is the canonical source of truth. -.env TERMINAL_CWD and MESSAGING_CWD are deprecated. -See issue #10817. +Rules: +- Local backend CLI/TUI: always os.getcwd(), ignoring config and inherited env. +- Non-local with placeholder: pop cwd for backend default. +- Non-local with explicit path: keep as-is. """ import os import pytest - -# The sentinel values that mean "resolve at runtime" _CWD_PLACEHOLDERS = (".", "auto", "cwd") -def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict): - """Simulate the CWD resolution logic from load_cli_config(). +def _resolve_cwd(terminal_config: dict, defaults: dict, env: dict): + """Mirror the CWD resolution logic from cli.py load_cli_config().""" + effective_backend = terminal_config.get("env_type", "local") - This mirrors the code in cli.py that checks for a pre-resolved - TERMINAL_CWD before falling back to os.getcwd(). - """ - 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) + if effective_backend == "local": + terminal_config["cwd"] = "/fake/getcwd" + defaults["terminal"]["cwd"] = terminal_config["cwd"] + elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS: + terminal_config.pop("cwd", None) - # Simulate the bridging loop: write terminal_config["cwd"] to env - _file_has_terminal = defaults.get("_file_has_terminal", False) + # Bridge: TERMINAL_CWD always exported in CLI, skipped in gateway + _is_gateway = env.get("_HERMES_GATEWAY") == "1" if "cwd" in terminal_config: - if _file_has_terminal or "TERMINAL_CWD" not in env: + if _is_gateway: + pass # don't touch env + else: env["TERMINAL_CWD"] = str(terminal_config["cwd"]) return env.get("TERMINAL_CWD", "") -class TestLazyImportGuard: - """TERMINAL_CWD resolved by gateway must survive a lazy cli.py import.""" +class TestLocalBackendCli: + """Local backend always uses os.getcwd().""" - def test_gateway_resolved_cwd_survives(self): - """Gateway set TERMINAL_CWD → lazy cli import must not clobber.""" - env = {"TERMINAL_CWD": "/home/user/workspace"} - terminal_config = {"cwd": ".", "env_type": "local"} - defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False} - - result = _resolve_terminal_cwd(terminal_config, defaults, env) - assert result == "/home/user/workspace" - - def test_gateway_resolved_cwd_survives_with_file_terminal(self): - """Even when config.yaml has a terminal: section, resolved CWD survives.""" - env = {"TERMINAL_CWD": "/home/user/workspace"} - terminal_config = {"cwd": ".", "env_type": "local"} - defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": True} - - result = _resolve_terminal_cwd(terminal_config, defaults, env) - assert result == "/home/user/workspace" - - -class TestConfigCwdResolution: - """config.yaml terminal.cwd is the canonical source of truth.""" - - 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 == "/explicit/path" - - def test_dot_cwd_resolves_to_getcwd_when_no_prior(self): - """With no pre-set TERMINAL_CWD, "." resolves to os.getcwd().""" + def test_explicit_config_ignored(self): env = {} - terminal_config = {"cwd": "."} - defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False} + tc = {"cwd": "/explicit/path", "env_type": "local"} + d = {"terminal": {"cwd": "/explicit/path"}} + assert _resolve_cwd(tc, d, env) == "/fake/getcwd" - result = _resolve_terminal_cwd(terminal_config, defaults, env) + def test_inherited_env_overwritten(self): + env = {"TERMINAL_CWD": "/parent/hermes"} + tc = {"cwd": "/home/user", "env_type": "local"} + d = {"terminal": {"cwd": "/home/user"}} + assert _resolve_cwd(tc, d, env) == "/fake/getcwd" + + def test_placeholder_resolved(self): + env = {} + tc = {"cwd": "."} + d = {"terminal": {"cwd": "."}} + assert _resolve_cwd(tc, d, env) == "/fake/getcwd" + + def test_env_and_no_config_file(self): + env = {"TERMINAL_CWD": "/stale/value"} + tc = {"cwd": ".", "env_type": "local"} + d = {"terminal": {"cwd": "."}} + assert _resolve_cwd(tc, d, env) == "/fake/getcwd" + + +class TestNonLocalBackends: + """Non-local backends use config or per-backend defaults.""" + + def test_placeholder_popped(self): + env = {} + tc = {"cwd": ".", "env_type": "docker"} + d = {"terminal": {"cwd": "."}} + assert _resolve_cwd(tc, d, env) == "" + + def test_explicit_path_kept(self): + env = {} + tc = {"cwd": "/srv/app", "env_type": "ssh"} + d = {"terminal": {"cwd": "/srv/app"}} + assert _resolve_cwd(tc, d, env) == "/srv/app" + + def test_auto_placeholder_popped(self): + env = {} + tc = {"cwd": "auto", "env_type": "modal"} + d = {"terminal": {"cwd": "auto"}} + assert _resolve_cwd(tc, d, env) == "" + + +class TestGatewayLazyImport: + """Gateway lazy import of cli.py must not clobber TERMINAL_CWD.""" + + def test_gateway_cwd_preserved(self): + env = {"_HERMES_GATEWAY": "1", "TERMINAL_CWD": "/home/user/project"} + tc = {"cwd": "/home/user", "env_type": "local"} + d = {"terminal": {"cwd": "/home/user"}} + result = _resolve_cwd(tc, d, env) + assert result == "/home/user/project" + + def test_cli_overwrites_stale_env(self): + env = {"TERMINAL_CWD": "/stale/from/dotenv"} + tc = {"cwd": "/home/user", "env_type": "local"} + d = {"terminal": {"cwd": "/home/user"}} + result = _resolve_cwd(tc, d, env) assert result == "/fake/getcwd" - - def test_remote_backend_pops_cwd(self): - """Remote backend + placeholder cwd → popped for backend default.""" - env = {} - terminal_config = {"cwd": ".", "env_type": "docker"} - defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False} - - result = _resolve_terminal_cwd(terminal_config, defaults, env) - assert result == "" # cwd popped, no env var set - - def test_remote_backend_with_prior_cwd_preserves(self): - """Remote backend + pre-resolved TERMINAL_CWD → adopted.""" - env = {"TERMINAL_CWD": "/project"} - terminal_config = {"cwd": ".", "env_type": "docker"} - defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False} - - result = _resolve_terminal_cwd(terminal_config, defaults, env) - assert result == "/project" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index aa971c7103..ec2c5ec0e8 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -187,7 +187,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 terminal sessions (gateway/cron only; CLI uses launch dir) | | `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..517cb2e988 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 always uses launch dir) 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