fix(docker): auto-mount host CWD to /workspace
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.
This commit is contained in:
@@ -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}"
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user