{"repo":"NousResearch/hermes-agent","public_id":"9730","display_label":"Docker sandbox init process compatibility issues","summary":"These issues and PRs address problems with Docker terminal sandboxes failing to start when using s6 /init images or when the --init flag is not permitted on the host system.","subsystem":"environments","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":0},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34635},"resolution":{"state":"resolved","by":"pr-34635,pr-103726"},"freshness":{"state":"verified","last_verified_at":"2026-09-05T15:30:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":9730,"pr_number":103726,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34628,"pr_number":34635,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":9730,"title":"Docker sandbox containers crash on hosts where --init is not permitted","url":null,"state":"closed"},{"kind":"issue","number":34628,"title":"[Bug]: Docker terminal sandboxes fail to start for s6 /init images","url":null,"state":"closed"},{"kind":"pr","number":34635,"title":"fix(docker): support s6 /init images in terminal sandbox (#34628)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_docker_environment.py","tools/environments/docker.py"],"fix_hunks":[{"file":"tests/tools/test_docker_environment.py","hunks":["@@ -1483,3 +1483,115 @@ def test_credential_mount_works_when_source_is_valid_file(monkeypatch, tmp_path)\n     assert run_calls, \"docker run should have been called\"\n     run_args_str = \" \".join(run_calls[0][0])\n     assert \"token.json\" in run_args_str\n+\n+\n+# ── s6-overlay /init image handling (issue #34628) ────────────────\n+\n+\n+def _mock_subprocess_run_with_entrypoint(monkeypatch, entrypoint_json):\n+    \"\"\"Like _mock_subprocess_run, but `docker image inspect` returns the given\n+    entrypoint JSON so _image_uses_init_entrypoint can be exercised end-to-end.\n+    \"\"\"\n+    calls = []\n+\n+    def _run(cmd, **kwargs):\n+        calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))\n+        if isinstance(cmd, list) and len(cmd) >= 2:\n+            if cmd[1] == \"version\":\n+                return subprocess.CompletedProcess(cmd, 0, stdout=\"Docker version\", stderr=\"\")\n+            if cmd[1] == \"image\" and len(cmd) >= 3 and cmd[2] == \"inspect\":\n+                return subprocess.CompletedProcess(cmd, 0, stdout=entrypoint_json + \"\\n\", stderr=\"\")\n+            if cmd[1] == \"run\":\n+                return subprocess.CompletedProcess(cmd, 0, stdout=\"fake-container-id\\n\", stderr=\"\")\n+        return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+    return calls\n+\n+\n+def test_image_uses_init_entrypoint_detects_s6_init(monkeypatch):\n+    \"\"\"An image whose entrypoint is /init is detected as an s6-overlay image.\"\"\"\n+    def _run(cmd, **kwargs):\n+        return subprocess.CompletedProcess(cmd, 0, stdout='[\"/init\"]', stderr=\"\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+    assert docker_env._image_uses_init_entrypoint(\"/usr/bin/docker\", \"hermes-agent:latest\") is True\n+\n+\n+def test_image_uses_init_entrypoint_false_for_plain_image(monkeypatch):\n+    \"\"\"A normal image (no /init entrypoint) is not treated as s6-overlay.\"\"\"\n+    def _run(cmd, **kwargs):\n+        return subprocess.CompletedProcess(cmd, 0, stdout='[\"/bin/sh\",\"-c\"]', stderr=\"\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+    assert docker_env._image_uses_init_entrypoint(\"/usr/bin/docker\", \"python:3.11\") is False\n+\n+\n+def test_image_uses_init_entrypoint_false_for_null_entrypoint(monkeypatch):\n+    \"\"\"Images with no declared entrypoint (null) keep hardened defaults.\"\"\"\n+    def _run(cmd, **kwargs):\n+        return subprocess.CompletedProcess(cmd, 0, stdout=\"null\", stderr=\"\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+    assert docker_env._image_uses_init_entrypoint(\"/usr/bin/docker\", \"alpine\") is False\n+\n+\n+def test_image_uses_init_entrypoint_false_on_inspect_failure(monkeypatch):\n+    \"\"\"An inspect failure (e.g. image not pulled) is best-effort -> defaults kept.\"\"\"\n+    def _run(cmd, **kwargs):\n+        return subprocess.CompletedProcess(cmd, 1, stdout=\"\", stderr=\"No such image\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+    assert docker_env._image_uses_init_entrypoint(\"/usr/bin/docker\", \"missing:tag\") is False\n+\n+\n+def test_image_uses_init_entrypoint_false_on_exception(monkeypatch):\n+    \"\"\"A subprocess error never raises out of detection — defaults kept.\"\"\"\n+    def _run(cmd, **kwargs):\n+        raise OSError(\"docker daemon down\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+    assert docker_env._image_uses_init_entrypoint(\"/usr/bin/docker\", \"x\") is False\n+\n+\n+def test_s6_image_skips_docker_init_and_mounts_run_exec(monkeypatch):\n+    \"\"\"For an s6-overlay /init image, docker run must omit --init and mount\n+    /run with exec (issue #34628).\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    calls = _mock_subprocess_run_with_entrypoint(monkeypatch, '[\"/init\"]')\n+\n+    _make_dummy_env(image=\"hermes-agent:latest\")\n+\n+    run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"run\"]\n+    assert run_calls, \"docker run should have been called\"\n+    run_args = run_calls[0][0]\n+\n+    assert \"--init\" not in run_args, \"s6 /init image must not get Docker --init\"\n+\n+    tmpfs_vals = [run_args[i + 1] for i, a in enumerate(run_args[:-1]) if a == \"--tmpfs\"]\n+    run_mounts = [v for v in tmpfs_vals if v.startswith(\"/run:\")]\n+    assert run_mounts, f\"no /run tmpfs mount found in {tmpfs_vals}\"\n+    assert \"exec\" in run_mounts[0] and \"noexec\" not in run_mounts[0], (\n+        f\"/run must be mounted exec for s6 images, got: {run_mounts[0]}\"\n+    )\n+\n+\n+def test_plain_image_keeps_docker_init_and_run_noexec(monkeypatch):\n+    \"\"\"A non-s6 image keeps the hardened defaults: Docker --init and noexec /run.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    calls = _mock_subprocess_run_with_entrypoint(monkeypatch, '[\"/bin/sh\",\"-c\"]')\n+\n+    _make_dummy_env(image=\"python:3.11\")\n+\n+    run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"run\"]\n+    assert run_calls, \"docker run should have been called\"\n+    run_args = run_calls[0][0]\n+\n+    assert \"--init\" in run_args, \"non-s6 image must keep Docker --init\"\n+\n+    tmpfs_vals = [run_args[i + 1] for i, a in enumerate(run_args[:-1]) if a == \"--tmpfs\"]\n+    run_mounts = [v for v in tmpfs_vals if v.startswith(\"/run:\")]\n+    assert run_mounts, f\"no /run tmpfs mount found in {tmpfs_vals}\"\n+    assert \"noexec\" in run_mounts[0], (\n+        f\"/run must stay noexec for non-s6 images, got: {run_mounts[0]}\"\n+    )"]},{"file":"tools/environments/docker.py","hunks":["@@ -5,6 +5,7 @@\n persistence via bind mounts.\n \"\"\"\n \n+import json\n import logging\n import os\n import re","@@ -329,9 +330,15 @@ def find_docker() -> Optional[str]:\n     \"--pids-limit\", \"256\",\n     \"--tmpfs\", \"/tmp:rw,nosuid,size=512m\",\n     \"--tmpfs\", \"/var/tmp:rw,noexec,nosuid,size=256m\",\n-    \"--tmpfs\", \"/run:rw,noexec,nosuid,size=64m\",\n ]\n \n+# /run is split out from _BASE_SECURITY_ARGS because s6-overlay images need it\n+# mounted ``exec``: s6 stage0 later runs ``exec /run/s6/basedir/bin/init``, which\n+# fails with \"Permission denied\" (exit 126) on a ``noexec`` mount. For all other\n+# images we keep the hardened ``noexec`` default.\n+_RUN_TMPFS_NOEXEC = \"--tmpfs\", \"/run:rw,noexec,nosuid,size=64m\"\n+_RUN_TMPFS_EXEC = \"--tmpfs\", \"/run:rw,exec,nosuid,size=64m\"\n+\n # Extra caps needed when the container starts as root and an init/entrypoint\n # must drop privileges (via `s6-setuidgid`, `gosu`, `su`, or similar).\n # Skipped when --user is passed because the container already starts","@@ -342,11 +349,63 @@ def find_docker() -> Optional[str]:\n ]\n \n \n-def _build_security_args(run_as_host_user: bool) -> list[str]:\n-    \"\"\"Return the security/cap/tmpfs args tailored to the privilege mode.\"\"\"\n+def _build_security_args(run_as_host_user: bool, run_exec: bool = False) -> list[str]:\n+    \"\"\"Return the security/cap/tmpfs args tailored to the privilege mode.\n+\n+    ``run_exec`` mounts ``/run`` with ``exec`` instead of the hardened\n+    ``noexec`` default. This is required for s6-overlay images whose ``/init``\n+    entrypoint execs ``/run/s6/basedir/bin/init`` during startup; see\n+    ``_image_uses_init_entrypoint``.\n+    \"\"\"\n+    run_tmpfs = list(_RUN_TMPFS_EXEC if run_exec else _RUN_TMPFS_NOEXEC)\n+    args = list(_BASE_SECURITY_ARGS) + run_tmpfs\n     if run_as_host_user:\n-        return list(_BASE_SECURITY_ARGS)\n-    return list(_BASE_SECURITY_ARGS) + list(_PRIVDROP_CAP_ARGS)\n+        return args\n+    return args + list(_PRIVDROP_CAP_ARGS)\n+\n+\n+def _image_uses_init_entrypoint(docker_exe: str, image: str) -> bool:\n+    \"\"\"Return True if ``image``'s entrypoint is the s6-overlay ``/init``.\n+\n+    Such images (e.g. anything built on ``s6-overlay``, including\n+    ``hermes-agent:latest``) already provide their own PID-1 init and execute\n+    ``/run/s6/basedir/bin/init`` during stage0 startup. They are incompatible\n+    with Docker's ``--init`` (two competing PID-1 inits) and with a ``noexec``\n+    ``/run`` mount. Detection is best-effort: on any inspection failure we\n+    return False and keep the hardened defaults.\n+    \"\"\"\n+    try:\n+        result = subprocess.run(\n+            [docker_exe, \"image\", \"inspect\", image,\n+             \"--format\", \"{{json .Config.Entrypoint}}\"],\n+            capture_output=True,\n+            text=True,\n+            timeout=15,\n+        )\n+    except (subprocess.SubprocessError, OSError) as e:\n+        logger.debug(\"Docker: could not inspect entrypoint for %s: %s\", image, e)\n+        return False\n+    if result.returncode != 0:\n+        # Image may not be pulled yet; the run will pull it. Defaults are safe\n+        # for non-s6 images, so don't block on this.\n+        logger.debug(\n+            \"Docker: image inspect for %s returned %d (stderr=%s)\",\n+            image, result.returncode, result.stderr.strip(),\n+        )\n+        return False\n+    raw = (result.stdout or \"\").strip()\n+    if not raw or raw == \"null\":\n+        return False\n+    try:\n+        entrypoint = json.loads(raw)\n+    except (ValueError, TypeError):\n+        return False\n+    if isinstance(entrypoint, str):\n+        entrypoint = [entrypoint]\n+    if not isinstance(entrypoint, list) or not entrypoint:\n+        return False\n+    first = str(entrypoint[0]).strip()\n+    return first in (\"/init\", \"/package/admin/s6-overlay/command/init\")\n \n \n def _resolve_host_user_spec() -> Optional[str]:","@@ -672,7 +731,28 @@ def __init__(\n                 )\n                 # Fall back to the full cap set — without --user, an image's\n                 # init may still need s6-setuidgid/gosu/su to drop privileges.\n-        security_args = _build_security_args(run_as_host_user and bool(user_args))\n+\n+        # Resolve the docker executable once so it works even when\n+        # /usr/local/bin is not in PATH (common on macOS gateway/service).\n+        self._docker_exe = find_docker() or \"docker\"\n+\n+        # s6-overlay images (e.g. hermes-agent:latest) already use /init as PID 1\n+        # and exec /run/s6/basedir/bin/init during startup. For those images we\n+        # must (a) skip Docker's --init (two competing PID-1 inits) and (b) mount\n+        # /run with exec instead of noexec, or s6 stage0 dies with exit 126\n+        # \"Permission denied\". Detected once here; defaults are kept on any\n+        # inspection failure. See issue #34628.\n+        image_uses_s6_init = _image_uses_init_entrypoint(self._docker_exe, image)\n+        if image_uses_s6_init:\n+            logger.info(\n+                \"Docker: image %s uses /init (s6-overlay) as entrypoint — \"\n+                \"skipping --init and mounting /run with exec.\",\n+                image,\n+            )\n+        security_args = _build_security_args(\n+            run_as_host_user and bool(user_args),\n+            run_exec=image_uses_s6_init,\n+        )\n \n         logger.info(f\"Docker volume_args: {volume_args}\")\n         # User-supplied extra docker run flags (docker_extra_args in config.yaml).","@@ -695,10 +775,6 @@ def __init__(\n         )\n         logger.info(f\"Docker run_args: {all_run_args}\")\n \n-        # Resolve the docker executable once so it works even when\n-        # /usr/local/bin is not in PATH (common on macOS gateway/service).\n-        self._docker_exe = find_docker() or \"docker\"\n-\n         # Start the container directly via `docker run -d`.\n         container_name = f\"hermes-{uuid.uuid4().hex[:8]}\"\n         # Labels make hermes-created containers identifiable to:","@@ -763,9 +839,13 @@ def __init__(\n                     reused = True\n \n         if not reused:\n+            # tini/catatonit as PID 1 reaps zombie children — but s6-overlay\n+            # images already provide their own /init PID 1, so adding --init\n+            # there creates two competing inits and breaks startup (#34628).\n+            init_args = [] if image_uses_s6_init else [\"--init\"]\n             run_cmd = [\n                 self._docker_exe, \"run\", \"-d\",\n-                \"--init\",           # tini/catatonit as PID 1 — reaps zombie children\n+                *init_args,\n                 \"--name\", container_name,\n                 *label_args,\n                 \"-w\", cwd,\n"]}]},{"kind":"pr","number":103726,"title":"docker: sandbox can start on snap-packaged Docker under AppArmor via terminal.docker_snap_compat (#9730)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_cli/config.py","hermes_cli/config_defaults.py","tests/tools/test_docker_environment.py","tests/tools/test_terminal_config_env_sync.py","tools/environments/docker.py","tools/terminal_tool.py","tools/terminal_tool_backends.py","website/docs/user-guide/configuration.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}