From e3ed594bb8619e96074b197703c2f05c1b041d3d Mon Sep 17 00:00:00 2001 From: Beomjoon Lee Date: Sat, 4 Jul 2026 16:44:56 +0900 Subject: [PATCH] fix(cli): stop is_container() false-positive on hosts running containers --- hermes_constants.py | 27 ++++++++++------ tests/test_hermes_constants.py | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/hermes_constants.py b/hermes_constants.py index c0f4d48e17254..124b6c70a3cbd 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -865,11 +865,14 @@ def is_container() -> bool: Kubernetes/k3s) were previously missed. To cover those, also check: * ``KUBERNETES_SERVICE_HOST`` env var — set in every Kubernetes pod. * ``kubepods`` / ``containerd`` / ``crio`` markers in ``/proc/1/cgroup``. - * the same markers in ``/proc/self/mountinfo`` (cgroup-v2 fallback). + * the same markers on the **root** mount (``/``) in + ``/proc/self/mountinfo`` (cgroup-v2 fallback). Only the root mount is + checked, so a host running its own containers isn't misread as one + (see #58135). Result is cached for the process lifetime. Import-safe — no heavy deps. - See: NousResearch/hermes-agent#47111 + See: NousResearch/hermes-agent#47111, #58135 """ global _container_detected if _container_detected is not None: @@ -893,15 +896,21 @@ def is_container() -> bool: return True except OSError: pass - # cgroup v2: /proc/1/cgroup is just "0::/" with no marker. The container - # runtime still shows up in the mount table (overlay rootfs, runtime mount - # paths), so scan mountinfo as a last resort. + # cgroup v2 collapses /proc/1/cgroup to "0::/", so fall back to mountinfo — + # but only the root mount ("/") reflects our own rootfs (a runtime overlay + # in a container, a plain block device on a host). Containers the *host* + # runs put containerd/crio in other mounts' lowerdir= options; scanning the + # whole table matched those and misclassified the host (#58135). try: with open("/proc/self/mountinfo", "r", encoding="utf-8") as f: - mountinfo = f.read() - if any(marker in mountinfo for marker in ("kubepods", "containerd", "crio")): - _container_detected = True - return True + for line in f: + # 5th field (index 4) is the mount point; only "/" is our rootfs. + fields = line.split() + if len(fields) > 4 and fields[4] == "/" and any( + marker in line for marker in ("kubepods", "containerd", "crio") + ): + _container_detected = True + return True except OSError: pass _container_detected = False diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index f23bed43ab8a0..8e9f43aeac238 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -415,6 +415,62 @@ def _fake_open(p, *a, **kw): monkeypatch.setattr("builtins.open", _fake_open) assert is_container() is True + def test_host_running_containers_not_flagged(self, monkeypatch, tmp_path): + """A host that merely runs containers is not itself a container (#58135).""" + import builtins + self._reset_cache(monkeypatch) + monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False) + monkeypatch.setattr(os.path, "exists", lambda p: False) + cgroup_file = tmp_path / "cgroup" + cgroup_file.write_text("0::/\n") # cgroup v2 — no runtime marker + mountinfo_file = tmp_path / "mountinfo" + mountinfo_file.write_text( + # host root: a real block device, no marker + "25 30 259:2 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p2 rw\n" + # a running container's overlay: mount point is not "/", marker only + # in the lowerdir= options + "469 554 0:94 / /var/lib/docker/rootfs/overlayfs/7dda83 rw,relatime " + "shared:247 - overlay overlay rw,lowerdir=/var/lib/containerd/" + "io.containerd.snapshotter.v1.overlayfs/snapshots/33509/fs\n" + ) + _real_open = builtins.open + + def _fake_open(p, *a, **kw): + if p == "/proc/1/cgroup": + return _real_open(str(cgroup_file), *a, **kw) + if p == "/proc/self/mountinfo": + return _real_open(str(mountinfo_file), *a, **kw) + return _real_open(p, *a, **kw) + + monkeypatch.setattr("builtins.open", _fake_open) + assert is_container() is False + + def test_detects_containerd_root_overlay_lowerdir(self, monkeypatch, tmp_path): + """Marker on the process's own root ("/") overlay still counts (#58135).""" + import builtins + self._reset_cache(monkeypatch) + monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False) + monkeypatch.setattr(os.path, "exists", lambda p: False) + cgroup_file = tmp_path / "cgroup" + cgroup_file.write_text("0::/\n") + mountinfo_file = tmp_path / "mountinfo" + mountinfo_file.write_text( + "1543 1542 0:158 / / rw,relatime - overlay overlay " + "rw,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1." + "overlayfs/snapshots/42/fs,upperdir=/run/containerd/x/fs\n" + ) + _real_open = builtins.open + + def _fake_open(p, *a, **kw): + if p == "/proc/1/cgroup": + return _real_open(str(cgroup_file), *a, **kw) + if p == "/proc/self/mountinfo": + return _real_open(str(mountinfo_file), *a, **kw) + return _real_open(p, *a, **kw) + + monkeypatch.setattr("builtins.open", _fake_open) + assert is_container() is True + def test_caches_result(self, monkeypatch): """Second call uses cached value without re-probing.""" monkeypatch.setattr(hermes_constants, "_container_detected", True)