From 8cdbbcaaa25f882bde6482a76c1f753edbd96f23 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Mon, 16 Mar 2026 03:35:35 -0400 Subject: [PATCH 1/3] 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 ead65528..3ed297b5 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 c04eff8d..1c95f7b3 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 fc22bf3f..a9326f3e 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 8adec23f..9a673bc7 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. From 780ddd102b1a8c8d1231ad44fd2035ced289d124 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Mon, 16 Mar 2026 05:19:43 -0700 Subject: [PATCH 2/3] fix(docker): gate cwd workspace mount behind config Keep Docker sandboxes isolated by default. Add an explicit terminal.docker_mount_cwd_to_workspace opt-in, thread it through terminal/file environment creation, and document the security tradeoff and config.yaml workflow clearly. --- cli-config.yaml.example | 4 +- cli.py | 2 + hermes_cli/config.py | 4 + tests/hermes_cli/test_set_config_value.py | 10 ++ tests/tools/test_docker_environment.py | 100 +++++++++--------- tests/tools/test_modal_sandbox_fixes.py | 60 ++++++++++- tools/environments/docker.py | 94 ++++++++-------- tools/file_tools.py | 1 + tools/terminal_tool.py | 39 ++++--- .../docs/reference/environment-variables.md | 1 + website/docs/user-guide/configuration.md | 48 ++++----- 11 files changed, 218 insertions(+), 145 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 7bc2c490..c493a309 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -76,8 +76,9 @@ model: # - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home) terminal: backend: "local" - cwd: "." # For local backend: "." = current directory. Ignored for remote backends. + cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise. timeout: 180 + docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace. lifetime_seconds: 300 # sudo_password: "" # Enable sudo commands (pipes via sudo -S) - SECURITY WARNING: plaintext! @@ -107,6 +108,7 @@ terminal: # timeout: 180 # lifetime_seconds: 300 # docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" +# docker_mount_cwd_to_workspace: true # Explicit opt-in: mount your launch cwd into /workspace # ----------------------------------------------------------------------------- # OPTION 4: Singularity/Apptainer container diff --git a/cli.py b/cli.py index 47018657..aa888fd6 100755 --- a/cli.py +++ b/cli.py @@ -165,6 +165,7 @@ def load_cli_config() -> Dict[str, Any]: "modal_image": "python:3.11", "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", "docker_volumes": [], # host:container volume mounts for Docker backend + "docker_mount_cwd_to_workspace": False, # explicit opt-in only; default off for sandbox isolation }, "browser": { "inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min @@ -330,6 +331,7 @@ def load_cli_config() -> Dict[str, Any]: "container_disk": "TERMINAL_CONTAINER_DISK", "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", "docker_volumes": "TERMINAL_DOCKER_VOLUMES", + "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "sandbox_dir": "TERMINAL_SANDBOX_DIR", # Persistent shell (non-local backends) "persistent_shell": "TERMINAL_PERSISTENT_SHELL", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f7813130..dbb37b28 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -118,6 +118,9 @@ DEFAULT_CONFIG = { # Each entry is "host_path:container_path" (standard Docker -v syntax). # Example: ["/home/user/projects:/workspace/projects", "/data:/data"] "docker_volumes": [], + # Explicit opt-in: mount the host cwd into /workspace for Docker sessions. + # Default off because passing host directories into a sandbox weakens isolation. + "docker_mount_cwd_to_workspace": False, # Persistent shell — keep a long-lived bash shell across execute() calls # so cwd/env vars/shell variables survive between commands. # Enabled by default for non-local backends (SSH); local is always opt-in @@ -1407,6 +1410,7 @@ def set_config_value(key: str, value: str): "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", "terminal.modal_image": "TERMINAL_MODAL_IMAGE", "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE", + "terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "terminal.cwd": "TERMINAL_CWD", "terminal.timeout": "TERMINAL_TIMEOUT", "terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR", diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 52a9d1a6..4eae64d6 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -115,3 +115,13 @@ class TestConfigYamlRouting: set_config_value("terminal.docker_image", "python:3.12") config = _read_config(_isolated_hermes_home) assert "python:3.12" in config + + def test_terminal_docker_cwd_mount_flag_goes_to_config_and_env(self, _isolated_hermes_home): + set_config_value("terminal.docker_mount_cwd_to_workspace", "true") + config = _read_config(_isolated_hermes_home) + env_content = _read_env(_isolated_hermes_home) + assert "docker_mount_cwd_to_workspace: 'true'" in config or "docker_mount_cwd_to_workspace: true" in config + assert ( + "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=true" in env_content + or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content + ) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 3ed297b5..499ebcd4 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -19,6 +19,8 @@ def _make_dummy_env(**kwargs): task_id=kwargs.get("task_id", "test-task"), volumes=kwargs.get("volumes", []), network=kwargs.get("network", True), + host_cwd=kwargs.get("host_cwd"), + auto_mount_cwd=kwargs.get("auto_mount_cwd", False), ) @@ -88,24 +90,16 @@ 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 + """Opt-in docker cwd mounting should bind the host cwd to /workspace.""" 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: @@ -120,33 +114,21 @@ def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path): MockInnerDocker, ) - # Create environment with host_cwd - env = docker_env.DockerEnvironment( - image="python:3.11", + _make_dummy_env( 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}" + assert f"{project_dir}:/workspace" in 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 - +def test_auto_mount_disabled_by_default(monkeypatch, tmp_path): + """Host cwd should not be mounted unless the caller explicitly opts in.""" 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="") @@ -167,26 +149,18 @@ def test_auto_mount_disabled_via_env(monkeypatch, tmp_path): MockInnerDocker, ) - env = docker_env.DockerEnvironment( - image="python:3.11", - cwd="/workspace", - timeout=60, - persistent_filesystem=False, - task_id="test-no-auto-mount", - volumes=[], + _make_dummy_env( + cwd="/root", host_cwd=str(project_dir), - auto_mount_cwd=True, + auto_mount_cwd=False, ) - # 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}" + assert f"{project_dir}:/workspace" not in 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 - + """Explicit user volumes for /workspace should take precedence over cwd mount.""" project_dir = tmp_path / "my-project" project_dir.mkdir() other_dir = tmp_path / "other" @@ -212,22 +186,52 @@ def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path MockInnerDocker, ) - # User already configured a volume mount for /workspace - env = docker_env.DockerEnvironment( - image="python:3.11", + _make_dummy_env( 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, + volumes=[f"{other_dir}:/workspace"], ) - # The user's explicit mount should be present run_args_str = " ".join(captured_run_args) assert f"{other_dir}:/workspace" in run_args_str + assert run_args_str.count(":/workspace") == 1 - # 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}" + +def test_auto_mount_replaces_persistent_workspace_bind(monkeypatch, tmp_path): + """Persistent mode should still prefer the configured host cwd at /workspace.""" + project_dir = tmp_path / "my-project" + project_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-persistent" + 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, + ) + + _make_dummy_env( + cwd="/workspace", + persistent_filesystem=True, + host_cwd=str(project_dir), + auto_mount_cwd=True, + task_id="test-persistent-auto-mount", + ) + + run_args_str = " ".join(captured_run_args) + assert f"{project_dir}:/workspace" in run_args_str + assert "/sandboxes/docker/test-persistent-auto-mount/workspace:/workspace" not in run_args_str diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py index 6da25216..49c30623 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/tests/tools/test_modal_sandbox_fixes.py @@ -91,8 +91,8 @@ class TestCwdHandling: "/home/ paths should be replaced for modal backend." ) - def test_users_path_replaced_for_docker(self): - """TERMINAL_CWD=/Users/... should be replaced with /root for docker.""" + def test_users_path_replaced_for_docker_by_default(self): + """Docker should keep host paths out of the sandbox unless explicitly enabled.""" with patch.dict(os.environ, { "TERMINAL_ENV": "docker", "TERMINAL_CWD": "/Users/someone/projects", @@ -100,8 +100,22 @@ class TestCwdHandling: config = _tt_mod._get_env_config() assert config["cwd"] == "/root", ( f"Expected /root, got {config['cwd']}. " - "/Users/ paths should be replaced for docker backend." + "Host paths should be discarded for docker backend by default." ) + assert config["host_cwd"] is None + assert config["docker_mount_cwd_to_workspace"] is False + + def test_users_path_maps_to_workspace_for_docker_when_enabled(self): + """Docker should map the host cwd into /workspace only when explicitly enabled.""" + with patch.dict(os.environ, { + "TERMINAL_ENV": "docker", + "TERMINAL_CWD": "/Users/someone/projects", + "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE": "true", + }): + config = _tt_mod._get_env_config() + assert config["cwd"] == "/workspace" + assert config["host_cwd"] == "/Users/someone/projects" + assert config["docker_mount_cwd_to_workspace"] is True def test_windows_path_replaced_for_modal(self): """TERMINAL_CWD=C:\\Users\\... should be replaced for modal.""" @@ -119,12 +133,27 @@ class TestCwdHandling: # Remove TERMINAL_CWD so it uses default env = os.environ.copy() env.pop("TERMINAL_CWD", None) + env.pop("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", None) with patch.dict(os.environ, env, clear=True): config = _tt_mod._get_env_config() assert config["cwd"] == "/root", ( f"Backend {backend}: expected /root default, got {config['cwd']}" ) + def test_docker_default_cwd_maps_current_directory_when_enabled(self): + """Docker should use /workspace when cwd mounting is explicitly enabled.""" + with patch("tools.terminal_tool.os.getcwd", return_value="/home/user/project"): + with patch.dict(os.environ, { + "TERMINAL_ENV": "docker", + "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE": "true", + }, clear=False): + env = os.environ.copy() + env.pop("TERMINAL_CWD", None) + with patch.dict(os.environ, env, clear=True): + config = _tt_mod._get_env_config() + assert config["cwd"] == "/workspace" + assert config["host_cwd"] == "/home/user/project" + def test_local_backend_uses_getcwd(self): """Local backend should use os.getcwd(), not /root.""" with patch.dict(os.environ, {"TERMINAL_ENV": "local"}, clear=False): @@ -134,6 +163,31 @@ class TestCwdHandling: config = _tt_mod._get_env_config() assert config["cwd"] == os.getcwd() + def test_create_environment_passes_docker_host_cwd_and_flag(self, monkeypatch): + """Docker host cwd and mount flag should reach DockerEnvironment.""" + captured = {} + sentinel = object() + + def _fake_docker_environment(**kwargs): + captured.update(kwargs) + return sentinel + + monkeypatch.setattr(_tt_mod, "_DockerEnvironment", _fake_docker_environment) + + env = _tt_mod._create_environment( + env_type="docker", + image="python:3.11", + cwd="/workspace", + timeout=60, + container_config={"docker_mount_cwd_to_workspace": True}, + host_cwd="/home/user/project", + ) + + assert env is sentinel + assert captured["cwd"] == "/workspace" + assert captured["host_cwd"] == "/home/user/project" + assert captured["auto_mount_cwd"] is True + def test_ssh_preserves_home_paths(self): """SSH backend should NOT replace /home/ paths (they're valid remotely).""" with patch.dict(os.environ, { diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 1c95f7b3..ec6d8b30 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -158,10 +158,6 @@ 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__( @@ -177,7 +173,7 @@ class DockerEnvironment(BaseEnvironment): volumes: list = None, network: bool = True, host_cwd: str = None, - auto_mount_cwd: bool = True, + auto_mount_cwd: bool = False, ): if cwd == "~": cwd = "/root" @@ -220,30 +216,9 @@ class DockerEnvironment(BaseEnvironment): # mode uses tmpfs (ephemeral, fast, gone on cleanup). from tools.environments.base import get_sandbox_dir - self._workspace_dir: Optional[str] = None - self._home_dir: Optional[str] = None - if self._persistent: - sandbox = get_sandbox_dir() / "docker" / task_id - self._workspace_dir = str(sandbox / "workspace") - self._home_dir = str(sandbox / "home") - os.makedirs(self._workspace_dir, exist_ok=True) - os.makedirs(self._home_dir, exist_ok=True) - writable_args = [ - "-v", f"{self._workspace_dir}:/workspace", - "-v", f"{self._home_dir}:/root", - ] - else: - writable_args = [ - "--tmpfs", "/workspace:rw,exec,size=10g", - "--tmpfs", "/home:rw,exec,size=1g", - "--tmpfs", "/root:rw,exec,size=1g", - ] - - # All containers get security hardening (capabilities dropped, no privilege - # escalation, PID limits). The container filesystem is writable so agents - # can install packages as needed. # User-configured volume mounts (from config.yaml docker_volumes) volume_args = [] + workspace_explicitly_mounted = False for vol in (volumes or []): if not isinstance(vol, str): logger.warning(f"Docker volume entry is not a string: {vol!r}") @@ -253,31 +228,52 @@ class DockerEnvironment(BaseEnvironment): continue if ":" in vol: volume_args.extend(["-v", vol]) + if ":/workspace" in vol: + workspace_explicitly_mounted = True 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}") + host_cwd_abs = os.path.abspath(os.path.expanduser(host_cwd)) if host_cwd else "" + bind_host_cwd = ( + auto_mount_cwd + and bool(host_cwd_abs) + and os.path.isdir(host_cwd_abs) + and not workspace_explicitly_mounted + ) + if auto_mount_cwd and host_cwd and not os.path.isdir(host_cwd_abs): + logger.debug(f"Skipping docker cwd mount: host_cwd is not a valid directory: {host_cwd}") + + self._workspace_dir: Optional[str] = None + self._home_dir: Optional[str] = None + writable_args = [] + if self._persistent: + sandbox = get_sandbox_dir() / "docker" / task_id + self._home_dir = str(sandbox / "home") + os.makedirs(self._home_dir, exist_ok=True) + writable_args.extend([ + "-v", f"{self._home_dir}:/root", + ]) + if not bind_host_cwd and not workspace_explicitly_mounted: + self._workspace_dir = str(sandbox / "workspace") + os.makedirs(self._workspace_dir, exist_ok=True) + writable_args.extend([ + "-v", f"{self._workspace_dir}:/workspace", + ]) + else: + if not bind_host_cwd and not workspace_explicitly_mounted: + writable_args.extend([ + "--tmpfs", "/workspace:rw,exec,size=10g", + ]) + writable_args.extend([ + "--tmpfs", "/home:rw,exec,size=1g", + "--tmpfs", "/root:rw,exec,size=1g", + ]) + + if bind_host_cwd: + logger.info(f"Mounting configured host cwd to /workspace: {host_cwd_abs}") + volume_args = ["-v", f"{host_cwd_abs}:/workspace", *volume_args] + elif workspace_explicitly_mounted: + logger.debug("Skipping docker cwd mount: /workspace already mounted by user config") logger.info(f"Docker volume_args: {volume_args}") all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args diff --git a/tools/file_tools.py b/tools/file_tools.py index 98ea15bd..ddcfcd56 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -140,6 +140,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: container_config=container_config, local_config=local_config, task_id=task_id, + host_cwd=config.get("host_cwd"), ) with _env_lock: diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index a9326f3e..49a82e24 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -466,6 +466,8 @@ def _get_env_config() -> Dict[str, Any]: default_image = "nikolaik/python-nodejs:python3.11-nodejs20" env_type = os.getenv("TERMINAL_ENV", "local") + mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in ("true", "1", "yes") + # Default cwd: local uses the host's current directory, everything # else starts in the user's home (~ resolves to whatever account # is running inside the container/remote). @@ -475,21 +477,25 @@ def _get_env_config() -> Dict[str, Any]: default_cwd = "~" else: default_cwd = "/root" - + # Read TERMINAL_CWD but sanity-check it for container 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. - # SSH is excluded since /home/ paths are valid on remote machines. - 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: + # If Docker cwd passthrough is explicitly enabled, remap the host path to + # /workspace and track the original host path separately. Otherwise keep the + # normal sandbox behavior and discard host paths. + cwd = os.getenv("TERMINAL_CWD", default_cwd) + host_cwd = None + host_prefixes = ("/Users/", "/home/", "C:\\", "C:/") + if env_type == "docker" and mount_docker_cwd: + docker_cwd_source = os.getenv("TERMINAL_CWD") or os.getcwd() + candidate = os.path.abspath(os.path.expanduser(docker_cwd_source)) + if ( + any(candidate.startswith(p) for p in host_prefixes) + or (os.path.isabs(candidate) and os.path.isdir(candidate) and not candidate.startswith(("/workspace", "/root"))) + ): + host_cwd = candidate + cwd = "/workspace" + elif env_type in ("modal", "docker", "singularity", "daytona") and cwd: # Host paths that won't exist inside containers - host_prefixes = ("/Users/", "/home/", "C:\\", "C:/") if any(cwd.startswith(p) for p in host_prefixes) and cwd != default_cwd: logger.info("Ignoring TERMINAL_CWD=%r for %s backend " "(host path won't exist in sandbox). Using %r instead.", @@ -503,7 +509,8 @@ 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 + "host_cwd": host_cwd, + "docker_mount_cwd_to_workspace": mount_docker_cwd, "timeout": _parse_env_var("TERMINAL_TIMEOUT", "180"), "lifetime_seconds": _parse_env_var("TERMINAL_LIFETIME_SECONDS", "300"), # SSH-specific config @@ -544,7 +551,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) + host_cwd: Optional host working directory to bind into Docker when explicitly enabled Returns: Environment instance with execute() method @@ -568,6 +575,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, persistent_filesystem=persistent, task_id=task_id, volumes=volumes, host_cwd=host_cwd, + auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False), ) elif env_type == "singularity": @@ -957,6 +965,7 @@ def terminal_tool( "container_disk": config.get("container_disk", 51200), "container_persistent": config.get("container_persistent", True), "docker_volumes": config.get("docker_volumes", []), + "docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False), } local_config = None diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 2b462e18..daaad87b 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -79,6 +79,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` | | `TERMINAL_DOCKER_IMAGE` | Docker image (default: `python:3.11`) | | `TERMINAL_DOCKER_VOLUMES` | Additional Docker volume mounts (comma-separated `host:container` pairs) | +| `TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE` | Advanced opt-in: mount the launch cwd into Docker `/workspace` (`true`/`false`, default: `false`) | | `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path | | `TERMINAL_MODAL_IMAGE` | Modal container image | | `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 9a673bc7..ab5e47ef 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -453,7 +453,8 @@ terminal: # Docker-specific settings docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" - docker_volumes: # Share host directories with the container + docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into /workspace. + docker_volumes: # Additional explicit host mounts - "/home/user/projects:/workspace/projects" - "/home/user/data:/data:ro" # :ro for read-only @@ -520,41 +521,30 @@ This is useful for: Can also be set via environment variable: `TERMINAL_DOCKER_VOLUMES='["/host:/container"]'` (JSON array). -### Docker Auto-Mount Current Directory +### Optional: Mount the Launch Directory into `/workspace` -When using the Docker backend, Hermes **automatically mounts your current working directory** to `/workspace` inside the container. This means you can: +Docker sandboxes stay isolated by default. Hermes does **not** pass your current host working directory into the container unless you explicitly opt in. -```bash -cd ~/projects/my-app -hermes -# The agent can now see and edit files in ~/projects/my-app via /workspace +Enable it in `config.yaml`: + +```yaml +terminal: + backend: docker + docker_mount_cwd_to_workspace: true ``` -No manual volume configuration needed — just `cd` to your project and run `hermes`. +When enabled: +- if you launch Hermes from `~/projects/my-app`, that host directory is bind-mounted to `/workspace` +- the Docker backend starts in `/workspace` +- file tools and terminal commands both see the same mounted project -**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 +When disabled, `/workspace` stays sandbox-owned unless you explicitly mount something via `docker_volumes`. -**Disabling auto-mount:** +Security tradeoff: +- `false` preserves the sandbox boundary +- `true` gives the sandbox direct access to the directory you launched Hermes from -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`. -::: +Use the opt-in only when you intentionally want the container to work on live host files. ### Persistent Shell From b72f522e30fbdc75e6bb50714e9063d00388672c Mon Sep 17 00:00:00 2001 From: teknium1 Date: Mon, 16 Mar 2026 05:40:05 -0700 Subject: [PATCH 3/3] test: fake minisweagent for docker cwd mount regressions Make the new Docker cwd-mount tests pass in CI environments that do not have the minisweagent package installed by injecting a fake module instead of monkeypatching an import path that may not exist. --- tests/tools/test_docker_environment.py | 72 +++++++++----------------- 1 file changed, 24 insertions(+), 48 deletions(-) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 499ebcd4..03b32d20 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -1,11 +1,31 @@ import logging import subprocess +import sys +import types import pytest from tools.environments import docker as docker_env +def _install_fake_minisweagent(monkeypatch, captured_run_args): + class MockInnerDocker: + container_id = "fake-container" + config = type("Config", (), {"executable": "/usr/bin/docker", "forward_env": [], "env": {}})() + + def __init__(self, **kwargs): + captured_run_args.extend(kwargs.get("run_args", [])) + + minisweagent_mod = types.ModuleType("minisweagent") + environments_mod = types.ModuleType("minisweagent.environments") + docker_mod = types.ModuleType("minisweagent.environments.docker") + docker_mod.DockerEnvironment = MockInnerDocker + + monkeypatch.setitem(sys.modules, "minisweagent", minisweagent_mod) + monkeypatch.setitem(sys.modules, "minisweagent.environments", environments_mod) + monkeypatch.setitem(sys.modules, "minisweagent.environments.docker", docker_mod) + + def _make_dummy_env(**kwargs): """Helper to construct DockerEnvironment with minimal required args.""" return docker_env.DockerEnvironment( @@ -101,18 +121,7 @@ def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path): monkeypatch.setattr(docker_env.subprocess, "run", _run_docker_version) 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, - ) + _install_fake_minisweagent(monkeypatch, captured_run_args) _make_dummy_env( cwd="/workspace", @@ -136,18 +145,7 @@ def test_auto_mount_disabled_by_default(monkeypatch, tmp_path): 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, - ) + _install_fake_minisweagent(monkeypatch, captured_run_args) _make_dummy_env( cwd="/root", @@ -173,18 +171,7 @@ def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path 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, - ) + _install_fake_minisweagent(monkeypatch, captured_run_args) _make_dummy_env( cwd="/workspace", @@ -210,18 +197,7 @@ def test_auto_mount_replaces_persistent_workspace_bind(monkeypatch, tmp_path): monkeypatch.setattr(docker_env.subprocess, "run", _run_docker_version) captured_run_args = [] - - class MockInnerDocker: - container_id = "mock-container-persistent" - 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, - ) + _install_fake_minisweagent(monkeypatch, captured_run_args) _make_dummy_env( cwd="/workspace",