# NousResearch/hermes-agent · Docker sandbox init process compatibility issues

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 4 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 0 PR(s) closed without merging.
- A fix was merged to main in PR #34635. Verify it addresses your case.
- **Recommended:** read PR #34635 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-34635,pr-103726`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.39`: the share of member 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.

## Closed / duplicate issues

- #9730: Docker sandbox containers crash on hosts where --init is not permitted
- #34628: \[Bug\]: Docker terminal sandboxes fail to start for s6 /init images

## Prior & competing PRs

- **#34635: fix(docker): support s6 /init images in terminal sandbox (#34628)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tests/tools/test_docker_environment.py`, `tools/environments/docker.py`
  - fix hunks:
  - `tests/tools/test_docker_environment.py`

    ```diff
    @@ -1483,3 +1483,115 @@ def test_credential_mount_works_when_source_is_valid_file(monkeypatch, tmp_path)
         assert run_calls, "docker run should have been called"
         run_args_str = " ".join(run_calls[0][0])
         assert "token.json" in run_args_str
    +
    +
    +# ── s6-overlay /init image handling (issue #34628) ────────────────
    +
    +
    +def _mock_subprocess_run_with_entrypoint(monkeypatch, entrypoint_json):
    +    """Like _mock_subprocess_run, but `docker image inspect` returns the given
    +    entrypoint JSON so _image_uses_init_entrypoint can be exercised end-to-end.
    +    """
    +    calls = []
    +
    +    def _run(cmd, **kwargs):
    +        calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))
    +        if isinstance(cmd, list) and len(cmd) >= 2:
    +            if cmd[1] == "version":
    +                return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="")
    +            if cmd[1] == "image" and len(cmd) >= 3 and cmd[2] == "inspect":
    +                return subprocess.CompletedProcess(cmd, 0, stdout=entrypoint_json + "\n", stderr="")
    +            if cmd[1] == "run":
    +                return subprocess.CompletedProcess(cmd, 0, stdout="fake-container-id\n", stderr="")
    +        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
    +
    +    monkeypatch.setattr(docker_env.subprocess, "run", _run)
    +    return calls
    +
    +
    +def test_image_uses_init_entrypoint_detects_s6_init(monkeypatch):
    +    """An image whose entrypoint is /init is detected as an s6-overlay image."""
    +    def _run(cmd, **kwargs):
    +        return subprocess.CompletedProcess(cmd, 0, stdout='["/init"]', stderr="")
    +
    +    monkeypatch.setattr(docker_env.subprocess, "run", _run)
    +    assert docker_env._image_uses_init_entrypoint("/usr/bin/docker", "hermes-agent:latest") is True
    +
    +
    +def test_image_uses_init_entrypoint_false_for_plain_image(monkeypatch):
    +    """A normal image (no /init entrypoint) is not treated as s6-overlay."""
    +    def _run(cmd, **kwargs):
    +        return subprocess.CompletedProcess(cmd, 0, stdout='["/bin/sh","-c"]', stderr="")
    +
    +    monkeypatch.setattr(docker_env.subprocess, "run", _run)
    +    assert docker_env._image_uses_init_entrypoint("/usr/bin/docker", "python:3.11") is False
    +
    +
    +def test_image_uses_init_entrypoint_false_for_null_entrypoint(monkeypatch):
    +    """Images with no declared entrypoint (null) keep hardened defaults."""
    +    def _run(cmd, **kwargs):
    +        return subprocess.CompletedProcess(cmd, 0, stdout="null", stderr="")
    +
    +    monkeypatch.setattr(docker_env.subprocess, "run", _run)
    +    assert docker_env._image_uses_init_entrypoint("/usr/bin/docker", "alpine") is False
    +
    +
    +def test_image_uses_init_entrypoint_false_on_inspect_failure(monkeypatch):
    +    """An inspect failure (e.g. image not pulled) is best-effort -> defaults kept."""
    +    def _run(cmd, **kwargs):
    +        return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="No such image")
    +
    +    monkeypatch.setattr(docker_env.subprocess, "run", _run)
    +    assert docker_env._image_uses_init_entrypoint("/usr/bin/docker", "missing:tag") is False
    +
    +
    +def test_image_uses_init_entrypoint_false_on_exception(monkeypatch):
    +    """A subprocess error never raises out of detection — defaults kept."""
    +    def _run(cmd, **kwargs):
    +        raise OSError("docker daemon down")
    +
    +    monkeypatch.setattr(docker_env.subprocess, "run", _run)
    +    assert docker_env._image_uses_init_entrypoint("/usr/bin/docker", "x") is False
    +
    +
    +def test_s6_image_skips_docker_init_and_mounts_run_exec(monkeypatch):
    +    """For an s6-overlay /init image, docker run must omit --init and mount
    +    /run with exec (issue #34628)."""
    +    monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
    +    calls = _mock_subprocess_run_with_entrypoint(monkeypatch, '["/init"]')
    +
    +    _make_dummy_env(image="hermes-agent:latest")
    +
    +    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"
    +    run_args = run_calls[0][0]
    +
    +    assert "--init" not in run_args, "s6 /init image must not get Docker --init"
    +
    +    tmpfs_vals = [run_args[i + 1] for i, a in enumerate(run_args[:-1]) if a == "--tmpfs"]
    +    run_mounts = [v for v in tmpfs_vals if v.startswith("/run:")]
    +    assert run_mounts, f"no /run tmpfs mount found in {tmpfs_vals}"
    +    assert "exec" in run_mounts[0] and "noexec" not in run_mounts[0], (
    +        f"/run must be mounted exec for s6 images, got: {run_mounts[0]}"
    +    )
    +
    +
    +def test_plain_image_keeps_docker_init_and_run_noexec(monkeypatch):
    +    """A non-s6 image keeps the hardened defaults: Docker --init and noexec /run."""
    +    monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
    +    calls = _mock_subprocess_run_with_entrypoint(monkeypatch, '["/bin/sh","-c"]')
    +
    +    _make_dummy_env(image="python:3.11")
    +
    +    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"
    +    run_args = run_calls[0][0]
    +
    +    assert "--init" in run_args, "non-s6 image must keep Docker --init"
    +
    +    tmpfs_vals = [run_args[i + 1] for i, a in enumerate(run_args[:-1]) if a == "--tmpfs"]
    +    run_mounts = [v for v in tmpfs_vals if v.startswith("/run:")]
    +    assert run_mounts, f"no /run tmpfs mount found in {tmpfs_vals}"
    +    assert "noexec" in run_mounts[0], (
    +        f"/run must stay noexec for non-s6 images, got: {run_mounts[0]}"
    +    )
    ```
  - `tools/environments/docker.py`

    ```diff
    @@ -5,6 +5,7 @@
     persistence via bind mounts.
     """
     
    +import json
     import logging
     import os
     import re
    ```

    ```diff
    @@ -329,9 +330,15 @@ def find_docker() -> Optional[str]:
         "--pids-limit", "256",
         "--tmpfs", "/tmp:rw,nosuid,size=512m",
         "--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m",
    -    "--tmpfs", "/run:rw,noexec,nosuid,size=64m",
     ]
     
    +# /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
    +# images we keep the hardened ``noexec`` default.
    +_RUN_TMPFS_NOEXEC = "--tmpfs", "/run:rw,noexec,nosuid,size=64m"
    +_RUN_TMPFS_EXEC = "--tmpfs", "/run:rw,exec,nosuid,size=64m"
    +
     # Extra caps needed when the container starts as root and an init/entrypoint
     # must drop privileges (via `s6-setuidgid`, `gosu`, `su`, or similar).
     # Skipped when --user is passed because the container already starts
    ```

    ```diff
    @@ -342,11 +349,63 @@ def find_docker() -> Optional[str]:
     ]
     
     
    -def _build_security_args(run_as_host_user: bool) -> list[str]:
    -    """Return the security/cap/tmpfs args tailored to the privilege mode."""
    +def _build_security_args(run_as_host_user: bool, run_exec: bool = False) -> list[str]:
    +    """Return the security/cap/tmpfs args tailored to the privilege mode.
    +
    +    ``run_exec`` mounts ``/run`` with ``exec`` instead of the hardened
    +    ``noexec`` default. This is required for s6-overlay images whose ``/init``
    +    entrypoint execs ``/run/s6/basedir/bin/init`` during startup; see
    +    ``_image_uses_init_entrypoint``.
    +    """
    +    run_tmpfs = list(_RUN_TMPFS_EXEC if run_exec else _RUN_TMPFS_NOEXEC)
    +    args = list(_BASE_SECURITY_ARGS) + run_tmpfs
         if run_as_host_user:
    -        return list(_BASE_SECURITY_ARGS)
    -    return list(_BASE_SECURITY_ARGS) + list(_PRIVDROP_CAP_ARGS)
    +        return args
    +    return args + list(_PRIVDROP_CAP_ARGS)
    +
    +
    +def _image_uses_init_entrypoint(docker_exe: str, image: str) -> bool:
    +    """Return True if ``image``'s entrypoint is the s6-overlay ``/init``.
    +
    +    Such images (e.g. anything built on ``s6-overlay``, including
    +    ``hermes-agent:latest``) already provide their own PID-1 init and execute
    +    ``/run/s6/basedir/bin/init`` during stage0 startup. They are incompatible
    +    with Docker's ``--init`` (two competing PID-1 inits) and with a ``noexec``
    +    ``/run`` mount. Detection is best-effort: on any inspection failure we
    +    return False and keep the hardened defaults.
    +    """
    +    try:
    +        result = subprocess.run(
    +            [docker_exe, "image", "inspect", image,
    +             "--format", "{{json .Config.Entrypoint}}"],
    +            capture_output=True,
    +            text=True,
    +            timeout=15,
    +        )
    +    except (subprocess.SubprocessError, OSError) as e:
    +        logger.debug("Docker: could not inspect entrypoint for %s: %s", image, e)
    +        return False
    +    if result.returncode != 0:
    +        # Image may not be pulled yet; the run will pull it. Defaults are safe
    +        # for non-s6 images, so don't block on this.
    +        logger.debug(
    +            "Docker: image inspect for %s returned %d (stderr=%s)",
    +            image, result.returncode, result.stderr.strip(),
    +        )
    +        return False
    +    raw = (result.stdout or "").strip()
    +    if not raw or raw == "null":
    +        return False
    +    try:
    +        entrypoint = json.loads(raw)
    +    except (ValueError, TypeError):
    +        return False
    +    if isinstance(entrypoint, str):
    +        entrypoint = [entrypoint]
    +    if not isinstance(entrypoint, list) or not entrypoint:
    +        return False
    +    first = str(entrypoint[0]).strip()
    +    return first in ("/init", "/package/admin/s6-overlay/command/init")
     
     
     def _resolve_host_user_spec() -> Optional[str]:
    ```

    ```diff
    @@ -672,7 +731,28 @@ def __init__(
                     )
                     # Fall back to the full cap set — without --user, an image's
                     # init may still need s6-setuidgid/gosu/su to drop privileges.
    -        security_args = _build_security_args(run_as_host_user and bool(user_args))
    +
    +        # Resolve the docker executable once so it works even when
    +        # /usr/local/bin is not in PATH (common on macOS gateway/service).
    +        self._docker_exe = find_docker() or "docker"
    +
    +        # s6-overlay images (e.g. hermes-agent:latest) already use /init as PID 1
    +        # and exec /run/s6/basedir/bin/init during startup. For those images we
    +        # must (a) skip Docker's --init (two competing PID-1 inits) and (b) mount
    +        # /run with exec instead of noexec, or s6 stage0 dies with exit 126
    +        # "Permission denied". Detected once here; defaults are kept on any
    +        # inspection failure. See issue #34628.
    +        image_uses_s6_init = _image_uses_init_entrypoint(self._docker_exe, image)
    +        if image_uses_s6_init:
    +            logger.info(
    +                "Docker: image %s uses /init (s6-overlay) as entrypoint — "
    +                "skipping --init and mounting /run with exec.",
    +                image,
    +            )
    +        security_args = _build_security_args(
    +            run_as_host_user and bool(user_args),
    +            run_exec=image_uses_s6_init,
    +        )
     
             logger.info(f"Docker volume_args: {volume_args}")
             # User-supplied extra docker run flags (docker_extra_args in config.yaml).
    ```

    ```diff
    @@ -695,10 +775,6 @@ def __init__(
             )
             logger.info(f"Docker run_args: {all_run_args}")
     
    -        # Resolve the docker executable once so it works even when
    -        # /usr/local/bin is not in PATH (common on macOS gateway/service).
    -        self._docker_exe = find_docker() or "docker"
    -
             # Start the container directly via `docker run -d`.
             container_name = f"hermes-{uuid.uuid4().hex[:8]}"
             # Labels make hermes-created containers identifiable to:
    ```

    ```diff
    @@ -763,9 +839,13 @@ def __init__(
                         reused = True
     
             if not reused:
    +            # tini/catatonit as PID 1 reaps zombie children — but s6-overlay
    +            # images already provide their own /init PID 1, so adding --init
    +            # there creates two competing inits and breaks startup (#34628).
    +            init_args = [] if image_uses_s6_init else ["--init"]
                 run_cmd = [
                     self._docker_exe, "run", "-d",
    -                "--init",           # tini/catatonit as PID 1 — reaps zombie children
    +                *init_args,
                     "--name", container_name,
                     *label_args,
                     "-w", cwd,
    ```
- **#103726: docker: sandbox can start on snap-packaged Docker under AppArmor via terminal.docker_snap_compat (#9730)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`

## Fix status

- Merged to main in PR #34635 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-05`.
- Subsystem: `environments`
