From c33feb6dc9d4401e8e5f55b026f17e8665e290e2 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Mon, 16 Feb 2026 22:30:04 -0800 Subject: [PATCH] Fix host CWD leaking into non-local terminal backends When using Modal, Docker, SSH, or Singularity as the terminal backend from the CLI, the agent resolved cwd: "." to the host machine's local path (e.g. /Users/rewbs/code/hermes-agent) and passed it to the remote sandbox, where it doesn't exist. All commands failed with "No such file or directory". Root cause: cli.py unconditionally resolved "." to os.getcwd() and wrote it to TERMINAL_CWD regardless of backend type. Every tool then used that host-local path as the working directory inside the remote environment. Fixes: - cli.py: only resolve "." to os.getcwd() for the local backend. For all remote backends (ssh, docker, modal, singularity), leave TERMINAL_CWD unset so the tool layer uses per-backend defaults (/root, /, ~, etc.) - terminal_tool.py: added sanity check -- if TERMINAL_CWD contains a host-local prefix (/Users/, /home/, C:\) for a non-local backend, log a warning and fall back to the backend's default - terminal_tool.py: SSH default CWD is now ~ instead of os.getcwd() - file_operations.py: last-resort CWD fallback changed from os.getcwd() to "/" so host paths never leak into remote file operations --- cli.py | 15 ++++++++++++--- tools/file_operations.py | 7 +++++-- tools/terminal_tool.py | 28 ++++++++++++++++++++++++---- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/cli.py b/cli.py index 937a5332e..7d47f5073 100755 --- a/cli.py +++ b/cli.py @@ -173,10 +173,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 + # 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. if terminal_config.get("cwd") in (".", "auto", "cwd"): - terminal_config["cwd"] = os.getcwd() - defaults["terminal"]["cwd"] = terminal_config["cwd"] + 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/tools/file_operations.py b/tools/file_operations.py index 7d399dc9b..4f7510622 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -257,9 +257,12 @@ class ShellFileOperations(FileOperations): cwd: Working directory (defaults to env's cwd or current directory) """ self.env = terminal_env - # Determine cwd from various possible sources + # Determine cwd from various possible sources. + # IMPORTANT: do NOT fall back to os.getcwd() -- that's the HOST's local + # path which doesn't exist inside container/cloud backends (modal, docker). + # If nothing provides a cwd, use "/" as a safe universal default. self.cwd = cwd or getattr(terminal_env, 'cwd', None) or \ - getattr(getattr(terminal_env, 'config', None), 'cwd', None) or os.getcwd() + getattr(getattr(terminal_env, 'config', None), 'cwd', None) or "/" # Cache for command availability checks self._command_cache: Dict[str, bool] = {} diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index c48508a8a..776a34a9c 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1197,22 +1197,42 @@ def _get_env_config() -> Dict[str, Any]: env_type = os.getenv("TERMINAL_ENV", "local") # Default cwd depends on backend: - # - local/ssh: current working directory (CLI resolves "." before we get here) - # - docker/singularity: /tmp inside the container (singularity bind-mounts /scratch there) - # - modal: /root (ephemeral cloud container, full filesystem access) + # - local: host's current working directory + # - ssh: remote user's home (agent code is local, execution is remote) + # - docker: / inside the container + # - singularity/modal: /root (ephemeral cloud/container) if env_type in ("modal", "singularity"): default_cwd = "/root" elif env_type == "docker": default_cwd = "/" + elif env_type == "ssh": + default_cwd = "~" else: default_cwd = os.getcwd() + # Read TERMINAL_CWD but sanity-check it for non-local backends. + # If the CWD looks like a host-local path that can't exist inside a + # container/sandbox, fall back to the backend's own default. This + # catches the case where cli.py (or .env) leaked the host's CWD. + cwd = os.getenv("TERMINAL_CWD", default_cwd) + if env_type in ("modal", "docker", "singularity", "ssh") and cwd: + # Paths containing common host-only prefixes are clearly wrong + # inside a container. Also catch Windows-style paths (C:\...). + host_prefixes = ("/Users/", "/home/", "C:\\", "C:/") + if any(cwd.startswith(p) for p in host_prefixes) and cwd != default_cwd: + if not os.getenv("HERMES_QUIET"): + print( + f"[Terminal] Ignoring TERMINAL_CWD={cwd!r} for {env_type} backend " + f"(host path won't exist in sandbox). Using {default_cwd!r} instead." + ) + cwd = default_cwd + return { "env_type": env_type, "docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image), "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image), - "cwd": os.getenv("TERMINAL_CWD", default_cwd), + "cwd": cwd, "timeout": int(os.getenv("TERMINAL_TIMEOUT", "60")), "lifetime_seconds": int(os.getenv("TERMINAL_LIFETIME_SECONDS", "300")), # SSH-specific config