Docker backend CWD-to-workspace mounting behavior

NousResearch/hermes-agent · cluster 1445 · environments

Data as of

Nothing open on GitHub. Fix merged in #1534. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #1534. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs concern whether and how the host current working directory is automatically mounted to /workspace in the Docker backend, including debate over making it automatic versus an explicit opt-in.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #1534 fix(docker): make cwd workspace mount explicit opt-in

    merged to main

    files: cli-config.yaml.example, cli.py, hermes_cli/config.py, tests/hermes_cli/test_set_config_value.py, tests/tools/test_docker_environment.py, tests/tools/test_modal_sandbox_fixes.py, tools/environments/docker.py, tools/file_tools.py, tools/terminal_tool.py, website/docs/reference/environment-variables.md, website/docs/user-guide/configuration.md

    Show the diff
    • 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
    • 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",
    • hermes_cli/config.py
      @@ -118,6 +118,9 @@ def ensure_hermes_home():
               # 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",
    • tests/hermes_cli/test_set_config_value.py
      @@ -115,3 +115,13 @@ def test_terminal_image_goes_to_config(self, _isolated_hermes_home):
               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
      +        )
    • 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(
      @@ -19,6 +39,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),
           )
       
       
      @@ -86,3 +108,106 @@ def _run(cmd, **kwargs):
               })
           ]
       
      +
      +def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path):
      +    """Opt-in docker cwd mounting should bind the host cwd to /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 = []
      +    _install_fake_minisweagent(monkeypatch, captured_run_args)
      +
      +    _make_dummy_env(
      +        cwd="/workspace",
      +        host_cwd=str(project_dir),
      +        auto_mount_cwd=True,
      +    )
      +
      +    run_args_str = " ".join(captured_run_args)
      +    assert f"{project_dir}:/workspace" in run_args_str
      +
      +
      +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()
      +
      +    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 = []
      +    _install_fake_minisweagent(monkeypatch, captured_run_args)
      +
      +    _make_dummy_env(
      +        cwd="/root",
      +        host_cwd=str(project_dir),
      +        auto_mount_cwd=False,
      +    )
      +
      +    run_args_str = " ".join(captured_run_args)
      +    assert f"{project_dir}:/workspace" not in run_args_str
      +
      +
      +def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path):
      +    """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"
      +    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 = []
      +    _install_fake_minisweagent(monkeypatch, captured_run_args)
      +
      +    _make_dummy_env(
      +        cwd="/workspace",
      +        host_cwd=str(project_dir),
      +        auto_mount_cwd=True,
      +        volumes=[f"{other_dir}:/workspace"],
      +    )
      +
      +    run_args_str = " ".join(captured_run_args)
      +    assert f"{other_dir}:/workspace" in run_args_str
      +    assert run_args_str.count(":/workspace") == 1
      +
      +
      +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 = []
      +    _install_fake_minisweagent(monkeypatch, captured_run_args)
      +
      +    _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
      +
    • tests/tools/test_modal_sandbox_fixes.py
      @@ -91,8 +91,8 @@ def test_home_path_replaced_for_modal(self):
                       "/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 @@ def test_users_path_replaced_for_docker(self):
                   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 @@ def test_default_cwd_is_root_for_container_backends(self):
                       # 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 @@ def test_local_backend_uses_getcwd(self):
                       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, {
    • tools/environments/docker.py
      @@ -172,6 +172,8 @@ def __init__(
               task_id: str = "default",
               volumes: list = None,
               network: bool = True,
      +        host_cwd: str = None,
      +        auto_mount_cwd: bool = False,
           ):
               if cwd == "~":
                   cwd = "/root"
      @@ -214,30 +216,9 @@ def __init__(
               # 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}")
      @@ -247,9 +228,53 @@ def __init__(
                       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")
       
      +        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
               logger.info(f"Docker run_args: {all_run_args}")
    • 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:
    • 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,16 +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.
      +    # 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)
      -    if env_type in ("modal", "docker", "singularity", "daytona") and 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.",
      @@ -498,6 +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,
      +        "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
      @@ -525,7 +538,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 +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: Optional host working directory to bind into Docker when explicitly enabled
               
           Returns:
               Environment instance with execute() method
      @@ -559,6 +574,8 @@ 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,
      +            auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False),
               )
           
           elif env_type == "singularity":
      @@ -948,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
      @@ -965,6 +983,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({
    • 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 |
    • 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,6 +521,31 @@ This is useful for:
       
       Can also be set via environment variable: `TERMINAL_DOCKER_VOLUMES='["/host:/container"]'` (JSON array).
       
      +### Optional: Mount the Launch Directory into `/workspace`
      +
      +Docker sandboxes stay isolated by default. Hermes does **not** pass your current host working directory into the container unless you explicitly opt in.
      +
      +Enable it in `config.yaml`:
      +
      +```yaml
      +terminal:
      +  backend: docker
      +  docker_mount_cwd_to_workspace: true
      +```
      +
      +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
      +
      +When disabled, `/workspace` stays sandbox-owned unless you explicitly mount something via `docker_volumes`.
      +
      +Security tradeoff:
      +- `false` preserves the sandbox boundary
      +- `true` gives the sandbox direct access to the directory you launched Hermes from
      +
      +Use the opt-in only when you intentionally want the container to work on live host files.
      +
       ### 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.

3 PRs closed without merging

From the close comments: superseded by another PR 3

  • #1479 fix(docker): map current cwd into /workspace · superseded by another PR → #1534
  • #1504 fix(docker): auto-mount host CWD to /workspace · superseded by another PR → #1534
  • #1515 fix: mount host cwd to /workspace in Docker backend · superseded by another PR → #1534
Closed issues · 1
  • Closed#1445[Bug]: Hermes does not map current directory into docker backend automatically.
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
environments
Merged PRs
#1534
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
16 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.