diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 6bd88fa194..0f748f8b2b 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1083,6 +1083,7 @@ def _probe_remote_backend(env_type: str) -> str | None: "docker_env": config.get("docker_env", {}), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), "docker_extra_args": config.get("docker_extra_args", []), + "docker_shm_size": config.get("docker_shm_size", "1g"), "docker_persist_across_processes": config.get("docker_persist_across_processes", True), "docker_orphan_reaper": config.get("docker_orphan_reaper", True), } diff --git a/cli.py b/cli.py index 8cca4fe099..380b1af8ae 100644 --- a/cli.py +++ b/cli.py @@ -680,6 +680,7 @@ def load_cli_config() -> Dict[str, Any]: "docker_volumes": "TERMINAL_DOCKER_VOLUMES", "docker_env": "TERMINAL_DOCKER_ENV", "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS", + "docker_shm_size": "TERMINAL_DOCKER_SHM_SIZE", "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "docker_network": "TERMINAL_DOCKER_NETWORK", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", diff --git a/gateway/run.py b/gateway/run.py index 4870a187cf..7dde08044d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1927,6 +1927,7 @@ if _config_path.exists(): "docker_volumes": "TERMINAL_DOCKER_VOLUMES", "docker_env": "TERMINAL_DOCKER_ENV", "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS", + "docker_shm_size": "TERMINAL_DOCKER_SHM_SIZE", "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "docker_network": "TERMINAL_DOCKER_NETWORK", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4523f92fd7..590de96b75 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3195,6 +3195,7 @@ TERMINAL_CONFIG_ENV_MAP = { "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "docker_network": "TERMINAL_DOCKER_NETWORK", "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS", + "docker_shm_size": "TERMINAL_DOCKER_SHM_SIZE", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 3fa9350af8..872038e4dc 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -317,6 +317,11 @@ DEFAULT_CONFIG = { # Docker runs with --network=none so commands cannot reach the network. "docker_network": True, "docker_extra_args": [], # Extra flags passed verbatim to docker run + # /dev/shm size for the Docker sandbox. Docker's 64 MB default silently + # breaks Chromium/Playwright and PyTorch DataLoader workers; tmpfs is + # lazily allocated so the higher ceiling costs nothing until used. + # Set to "" (or "0") to omit the flag and use Docker's default. + "docker_shm_size": "1g", # Explicit opt-in: run the Docker container as the host user's uid:gid # (via `--user`). When enabled, files written into bind-mounted dirs # (docker_volumes, the persistent workspace, or the auto-mounted cwd) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 3ff853db4a..d89a984008 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -53,6 +53,7 @@ def _make_dummy_env(**kwargs): run_as_host_user=kwargs.get("run_as_host_user", False), extra_args=kwargs.get("extra_args", []), persist_across_processes=kwargs.get("persist_across_processes", True), + shm_size=kwargs.get("shm_size", docker_env._DEFAULT_SHM_SIZE), ) @@ -1282,3 +1283,73 @@ def test_execute_does_not_recover_on_ordinary_failure(monkeypatch): result = env.execute("badcmd") assert result.get("returncode") == 127 assert "command not found" in result.get("output", "") + + +# ── /dev/shm size tests (ported from nanocoai/nanoclaw#2748) ───────────────── + + +def _shm_run_args(calls): + run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] + assert run_calls, "docker run should have been called" + return run_calls[0][0] + + +def test_shm_size_default_applied(monkeypatch): + """Docker's 64 MB /dev/shm default breaks Chromium and PyTorch DataLoader + workers; the sandbox must raise it by default.""" + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + _make_dummy_env() + + run_args = _shm_run_args(calls) + assert "--shm-size" in run_args + assert run_args[run_args.index("--shm-size") + 1] == docker_env._DEFAULT_SHM_SIZE + + +def test_shm_size_custom_value(monkeypatch): + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + _make_dummy_env(shm_size="256m") + + run_args = _shm_run_args(calls) + assert run_args[run_args.index("--shm-size") + 1] == "256m" + + +@pytest.mark.parametrize("opt_out", ["", "0", " ", None]) +def test_shm_size_opt_out_omits_flag(monkeypatch, opt_out): + """Empty / '0' / None fall back to Docker's built-in default (no flag).""" + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + _make_dummy_env(shm_size=opt_out) + + run_args = _shm_run_args(calls) + assert "--shm-size" not in run_args + assert not any(isinstance(a, str) and a.startswith("--shm-size=") for a in run_args) + + +@pytest.mark.parametrize("extra", [["--shm-size", "4g"], ["--shm-size=4g"]]) +def test_shm_size_skipped_when_user_sets_it_via_extra_args(monkeypatch, extra): + """A user-supplied --shm-size in docker_extra_args must win unambiguously: + our default is skipped rather than relying on flag-ordering behavior.""" + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + _make_dummy_env(extra_args=list(extra)) + + run_args = _shm_run_args(calls) + joined = " ".join(run_args) + assert joined.count("--shm-size") == 1, joined + assert "4g" in joined + + +def test_extra_args_set_shm_size_helper(): + assert docker_env._extra_args_set_shm_size(["--shm-size", "2g"]) is True + assert docker_env._extra_args_set_shm_size(["--shm-size=2g"]) is True + assert docker_env._extra_args_set_shm_size(["--memory", "512m"]) is False + assert docker_env._extra_args_set_shm_size([]) is False + assert docker_env._extra_args_set_shm_size(None) is False + # non-string entries must not crash (config.yaml can be malformed) + assert docker_env._extra_args_set_shm_size([42, None, "--shm-size=1g"]) is True diff --git a/tools/environments/docker.py b/tools/environments/docker.py index def1ac157f..0e8fce7b36 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -349,6 +349,29 @@ _BASE_SECURITY_ARGS = [ # cgroup ``pids`` controller is available (see ``_cgroup_limits_available``). _DEFAULT_PIDS_LIMIT = "256" +# Default /dev/shm size. Docker's built-in default is a tiny 64 MB, which +# silently breaks shared-memory-hungry workloads inside the sandbox: Chromium / +# Playwright renderers crash tabs, and PyTorch DataLoader workers die with +# "bus error" / "insufficient shared memory" once they exceed it. tmpfs is +# lazily allocated, so a 1g ceiling costs nothing until actually used (and +# usage still counts against the container's --memory cgroup limit). +# Configurable via ``terminal.docker_shm_size`` in config.yaml; an empty value +# (or "0") omits the flag and falls back to Docker's 64 MB default. +# Ported from nanocoai/nanoclaw#2748. +_DEFAULT_SHM_SIZE = "1g" + + +def _extra_args_set_shm_size(extra_args: list) -> bool: + """True when user-supplied docker_extra_args already set ``--shm-size``. + + In that case we skip our default so the user's value is unambiguous + (rather than relying on flag-ordering / last-wins behavior). + """ + return any( + isinstance(a, str) and (a == "--shm-size" or a.startswith("--shm-size=")) + for a in (extra_args or []) + ) + # /run is split out from _BASE_SECURITY_ARGS because s6-overlay images need it # mounted ``exec``: s6 stage0 later runs ``exec /run/s6/basedir/bin/init``, which # fails with "Permission denied" (exit 126) on a ``noexec`` mount. For all other @@ -839,6 +862,7 @@ class DockerEnvironment(BaseEnvironment): run_as_host_user: bool = False, extra_args: list = None, persist_across_processes: bool = True, + shm_size: str = _DEFAULT_SHM_SIZE, ): if cwd == "~": cwd = "/root" @@ -874,6 +898,12 @@ class DockerEnvironment(BaseEnvironment): resource_args.extend(["--memory", f"{memory}m"]) if _cgroup_limits_available(image): resource_args.extend(["--pids-limit", _DEFAULT_PIDS_LIMIT]) + # /dev/shm size (not cgroup-gated: --shm-size is a tmpfs mount option, + # no controller delegation required). Skip when the user already sets + # it via docker_extra_args, or opted out with an empty/"0" value. + shm = str(shm_size or "").strip() + if shm and shm != "0" and not _extra_args_set_shm_size(extra_args): + resource_args.extend(["--shm-size", shm]) if disk > 0 and sys.platform != "darwin": if self._storage_opt_supported(): resource_args.extend(["--storage-opt", f"size={disk}m"]) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index b02c4bd2f9..6e06e55b2c 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1441,11 +1441,13 @@ def _get_env_config() -> Dict[str, Any]: docker_volumes = _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON") docker_env = _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON") docker_extra_args = _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON") + docker_shm_size = os.getenv("TERMINAL_DOCKER_SHM_SIZE", "1g") else: docker_forward_env = [] docker_volumes = [] docker_env = {} docker_extra_args = [] + docker_shm_size = "1g" # Default cwd: local uses the host's current directory, ssh uses the # remote home, Vercel uses its documented workspace root, and everything @@ -1522,6 +1524,7 @@ def _get_env_config() -> Dict[str, Any]: "docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"}, "docker_network": os.getenv("TERMINAL_DOCKER_NETWORK", "true").lower() in {"true", "1", "yes"}, "docker_extra_args": docker_extra_args, + "docker_shm_size": docker_shm_size, # Cross-process container reuse (issue #20561). The docs claim # "ONE long-lived container shared across sessions" — this toggle # makes that real by probing for a labeled container at startup and @@ -1607,6 +1610,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, network=docker_network, extra_args=docker_extra_args, persist_across_processes=cc.get("docker_persist_across_processes", True), + shm_size=cc.get("docker_shm_size", "1g"), ) elif env_type == "singularity": @@ -2385,6 +2389,7 @@ def terminal_tool( "docker_env": config.get("docker_env", {}), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), "docker_extra_args": config.get("docker_extra_args", []), + "docker_shm_size": config.get("docker_shm_size", "1g"), "docker_network": config.get("docker_network", True), "docker_persist_across_processes": config.get("docker_persist_across_processes", True), "docker_orphan_reaper": config.get("docker_orphan_reaper", True),