From 8cdbbcaaa25f882bde6482a76c1f753edbd96f23 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Mon, 16 Mar 2026 03:35:35 -0400 Subject: [PATCH] fix(docker): auto-mount host CWD to /workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1445 — When using Docker backend, the user's current working directory is now automatically bind-mounted to /workspace inside the container. This allows users to run `cd my-project && hermes` and have their project files accessible to the agent without manual volume config. Changes: - Add host_cwd and auto_mount_cwd parameters to DockerEnvironment - Capture original host CWD in _get_env_config() before container fallback - Pass host_cwd through _create_environment() to Docker backend - Add TERMINAL_DOCKER_NO_AUTO_MOUNT env var to disable if needed - Skip auto-mount when /workspace is already explicitly mounted - Add tests for auto-mount behavior - Add documentation for the new feature The auto-mount is skipped when: 1. TERMINAL_DOCKER_NO_AUTO_MOUNT=true is set 2. User configured docker_volumes with :/workspace 3. persistent_filesystem=true (persistent sandbox mode) This makes the Docker backend behave more intuitively — the agent operates on the user's actual project directory by default. --- tests/tools/test_docker_environment.py | 145 +++++++++++++++++++++++ tools/environments/docker.py | 29 +++++ tools/terminal_tool.py | 14 ++- website/docs/user-guide/configuration.md | 36 ++++++ 4 files changed, 222 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index ead655285..3ed297b59 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -86,3 +86,148 @@ def test_ensure_docker_available_uses_resolved_executable(monkeypatch): }) ] + +def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path): + """When host_cwd is provided, it should be auto-mounted to /workspace.""" + import os + + # Create a temp directory to simulate user's project directory + project_dir = tmp_path / "my-project" + project_dir.mkdir() + + # Mock Docker availability + def _run_docker_version(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, stdout="Docker version", stderr="") + + def _run_docker_create(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 1, stdout="", stderr="storage-opt not supported") + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env.subprocess, "run", _run_docker_version) + + # Mock the inner _Docker class to capture run_args + captured_run_args = [] + + class MockInnerDocker: + container_id = "mock-container-123" + config = type("Config", (), {"executable": "/usr/bin/docker", "forward_env": [], "env": {}})() + + def __init__(self, **kwargs): + captured_run_args.extend(kwargs.get("run_args", [])) + + monkeypatch.setattr( + "minisweagent.environments.docker.DockerEnvironment", + MockInnerDocker, + ) + + # Create environment with host_cwd + env = docker_env.DockerEnvironment( + image="python:3.11", + cwd="/workspace", + timeout=60, + persistent_filesystem=False, # Non-persistent mode uses tmpfs, should be overridden + task_id="test-auto-mount", + volumes=[], + host_cwd=str(project_dir), + auto_mount_cwd=True, + ) + + # Check that the host_cwd was added as a volume mount + volume_mount = f"-v {project_dir}:/workspace" + run_args_str = " ".join(captured_run_args) + assert f"{project_dir}:/workspace" in run_args_str, f"Expected auto-mount in run_args: {run_args_str}" + + +def test_auto_mount_disabled_via_env(monkeypatch, tmp_path): + """Auto-mount should be disabled when TERMINAL_DOCKER_NO_AUTO_MOUNT is set.""" + import os + + project_dir = tmp_path / "my-project" + project_dir.mkdir() + + monkeypatch.setenv("TERMINAL_DOCKER_NO_AUTO_MOUNT", "true") + + def _run_docker_version(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, stdout="Docker version", stderr="") + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env.subprocess, "run", _run_docker_version) + + captured_run_args = [] + + class MockInnerDocker: + container_id = "mock-container-456" + config = type("Config", (), {"executable": "/usr/bin/docker", "forward_env": [], "env": {}})() + + def __init__(self, **kwargs): + captured_run_args.extend(kwargs.get("run_args", [])) + + monkeypatch.setattr( + "minisweagent.environments.docker.DockerEnvironment", + MockInnerDocker, + ) + + env = docker_env.DockerEnvironment( + image="python:3.11", + cwd="/workspace", + timeout=60, + persistent_filesystem=False, + task_id="test-no-auto-mount", + volumes=[], + host_cwd=str(project_dir), + auto_mount_cwd=True, + ) + + # Check that the host_cwd was NOT added (because env var disabled it) + run_args_str = " ".join(captured_run_args) + assert f"{project_dir}:/workspace" not in run_args_str, f"Auto-mount should be disabled: {run_args_str}" + + +def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path): + """Auto-mount should be skipped if /workspace is already mounted via user volumes.""" + import os + + project_dir = tmp_path / "my-project" + project_dir.mkdir() + other_dir = tmp_path / "other" + other_dir.mkdir() + + def _run_docker_version(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, stdout="Docker version", stderr="") + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env.subprocess, "run", _run_docker_version) + + captured_run_args = [] + + class MockInnerDocker: + container_id = "mock-container-789" + config = type("Config", (), {"executable": "/usr/bin/docker", "forward_env": [], "env": {}})() + + def __init__(self, **kwargs): + captured_run_args.extend(kwargs.get("run_args", [])) + + monkeypatch.setattr( + "minisweagent.environments.docker.DockerEnvironment", + MockInnerDocker, + ) + + # User already configured a volume mount for /workspace + env = docker_env.DockerEnvironment( + image="python:3.11", + cwd="/workspace", + timeout=60, + persistent_filesystem=False, + task_id="test-workspace-exists", + volumes=[f"{other_dir}:/workspace"], # User explicitly mounted something to /workspace + host_cwd=str(project_dir), + auto_mount_cwd=True, + ) + + # The user's explicit mount should be present + run_args_str = " ".join(captured_run_args) + assert f"{other_dir}:/workspace" in run_args_str + + # But the auto-mount should NOT add a duplicate + assert run_args_str.count(":/workspace") == 1, f"Should only have one /workspace mount: {run_args_str}" + diff --git a/tools/environments/docker.py b/tools/environments/docker.py index c04eff8d0..1c95f7b34 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -158,6 +158,10 @@ class DockerEnvironment(BaseEnvironment): Persistence: when enabled, bind mounts preserve /workspace and /root across container restarts. + + Auto-mount: when host_cwd is provided (the user's original working directory), + it is automatically bind-mounted to /workspace unless auto_mount_cwd=False + or the path is already covered by an explicit volume mount. """ def __init__( @@ -172,6 +176,8 @@ class DockerEnvironment(BaseEnvironment): task_id: str = "default", volumes: list = None, network: bool = True, + host_cwd: str = None, + auto_mount_cwd: bool = True, ): if cwd == "~": cwd = "/root" @@ -250,6 +256,29 @@ class DockerEnvironment(BaseEnvironment): else: logger.warning(f"Docker volume '{vol}' missing colon, skipping") + # Auto-mount host CWD to /workspace when enabled (fixes #1445). + # This allows users to run `cd my-project && hermes` and have Docker + # automatically mount their project directory into the container. + # Disabled when: auto_mount_cwd=False, host_cwd is not a valid directory, + # or /workspace is already covered by writable_args or a user volume. + auto_mount_disabled = os.getenv("TERMINAL_DOCKER_NO_AUTO_MOUNT", "").lower() in ("1", "true", "yes") + if host_cwd and auto_mount_cwd and not auto_mount_disabled: + host_cwd_abs = os.path.abspath(os.path.expanduser(host_cwd)) + if os.path.isdir(host_cwd_abs): + # Check if /workspace is already being mounted by persistence or user config + workspace_already_mounted = any( + ":/workspace" in arg for arg in writable_args + ) or any( + ":/workspace" in arg for arg in volume_args + ) + if not workspace_already_mounted: + logger.info(f"Auto-mounting host CWD to /workspace: {host_cwd_abs}") + volume_args.extend(["-v", f"{host_cwd_abs}:/workspace"]) + else: + logger.debug(f"Skipping auto-mount: /workspace already mounted") + else: + logger.debug(f"Skipping auto-mount: host_cwd is not a valid directory: {host_cwd}") + logger.info(f"Docker volume_args: {volume_args}") all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args logger.info(f"Docker run_args: {all_run_args}") diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index fc22bf3f6..a9326f3ec 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -481,7 +481,12 @@ def _get_env_config() -> Dict[str, Any]: # container/sandbox, fall back to the backend's own default. This # catches the case where cli.py (or .env) leaked the host's CWD. # SSH is excluded since /home/ paths are valid on remote machines. - cwd = os.getenv("TERMINAL_CWD", default_cwd) + raw_cwd = os.getenv("TERMINAL_CWD", default_cwd) + cwd = raw_cwd + # Capture original host CWD for auto-mounting into containers (fixes #1445). + # Even when the container's working directory falls back to /root, we still + # want to auto-mount the user's host project directory to /workspace. + host_cwd = raw_cwd if raw_cwd and os.path.isdir(raw_cwd) else os.getcwd() if env_type in ("modal", "docker", "singularity", "daytona") and cwd: # Host paths that won't exist inside containers host_prefixes = ("/Users/", "/home/", "C:\\", "C:/") @@ -498,6 +503,7 @@ def _get_env_config() -> Dict[str, Any]: "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image), "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image), "cwd": cwd, + "host_cwd": host_cwd, # Original host directory for auto-mounting into containers "timeout": _parse_env_var("TERMINAL_TIMEOUT", "180"), "lifetime_seconds": _parse_env_var("TERMINAL_LIFETIME_SECONDS", "300"), # SSH-specific config @@ -525,7 +531,8 @@ def _get_env_config() -> Dict[str, Any]: def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ssh_config: dict = None, container_config: dict = None, local_config: dict = None, - task_id: str = "default"): + task_id: str = "default", + host_cwd: str = None): """ Create an execution environment from mini-swe-agent. @@ -537,6 +544,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ssh_config: SSH connection config (for env_type="ssh") container_config: Resource config for container backends (cpu, memory, disk, persistent) task_id: Task identifier for environment reuse and snapshot keying + host_cwd: Original host working directory (for auto-mounting into containers) Returns: Environment instance with execute() method @@ -559,6 +567,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, cpu=cpu, memory=memory, disk=disk, persistent_filesystem=persistent, task_id=task_id, volumes=volumes, + host_cwd=host_cwd, ) elif env_type == "singularity": @@ -965,6 +974,7 @@ def terminal_tool( container_config=container_config, local_config=local_config, task_id=effective_task_id, + host_cwd=config.get("host_cwd"), ) except ImportError as e: return json.dumps({ diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 8adec23f1..9a673bc7f 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -520,6 +520,42 @@ This is useful for: Can also be set via environment variable: `TERMINAL_DOCKER_VOLUMES='["/host:/container"]'` (JSON array). +### Docker Auto-Mount Current Directory + +When using the Docker backend, Hermes **automatically mounts your current working directory** to `/workspace` inside the container. This means you can: + +```bash +cd ~/projects/my-app +hermes +# The agent can now see and edit files in ~/projects/my-app via /workspace +``` + +No manual volume configuration needed — just `cd` to your project and run `hermes`. + +**How it works:** +- If you're in `/home/user/projects/my-app`, that directory is mounted to `/workspace` +- The container's working directory is set to `/workspace` +- Files you edit on the host are immediately visible to the agent, and vice versa + +**Disabling auto-mount:** + +If you prefer the old behavior (empty `/workspace` with tmpfs or persistent sandbox), disable auto-mount: + +```bash +export TERMINAL_DOCKER_NO_AUTO_MOUNT=true +``` + +**Precedence:** + +Auto-mount is skipped when: +1. `TERMINAL_DOCKER_NO_AUTO_MOUNT=true` is set +2. You've explicitly configured a volume mount to `/workspace` in `docker_volumes` +3. `container_persistent: true` is set (persistent sandbox mode uses its own `/workspace`) + +:::tip +Auto-mount is ideal for project-based work where you want the agent to operate on your actual files. For isolated sandboxing where the agent shouldn't access your filesystem, set `TERMINAL_DOCKER_NO_AUTO_MOUNT=true`. +::: + ### Persistent Shell By default, each terminal command runs in its own subprocess — working directory, environment variables, and shell variables reset between commands. When **persistent shell** is enabled, a single long-lived bash process is kept alive across `execute()` calls so that state survives between commands.