is_container() detection accuracy: false positives and misses
NousResearch/hermes-agent · cluster 44721 · agent
Data as of
Nothing open on GitHub. Fix merged in #47131. Check it covers your case.
The short answer
- Known?
- Yes. 18 related items grouped here, 5 issues already closed.
- Fix event
- A fix was merged to main in PR #47131. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #47131, #105351. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes center on is_container() incorrectly identifying bare hosts as containers (false positives) or failing to detect Kubernetes/containerd/cgroup-v2 environments (false negatives). Both failure modes cause downstream problems such as wrong supervision behavior, gateway crashes, and broken UI controls.
Open issues0
Open PRs0
Fixes on record
-
#47131 fix(cli): detect containerd/CRI cgroup-v2 containers in is_container()
files:
hermes_constants.py,tests/test_hermes_constants.pyShow the diff
-
hermes_constants.py@@ -461,11 +461,21 @@ def is_wsl() -> bool: def is_container() -> bool: - """Return True when running inside a Docker/Podman container. + """Return True when running inside a container. - Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman), - and ``/proc/1/cgroup`` for container runtime markers. Result is - cached for the process lifetime. Import-safe — no heavy deps. + Recognizes Docker (``/.dockerenv``), Podman (``/run/.containerenv``), + and — via ``/proc/1/cgroup`` — the docker/podman/lxc cgroup-v1 markers. + + cgroup v2 collapses ``/proc/1/cgroup`` to a single ``0::/`` line with no + runtime marker, so containerd/CRI-O runtimes (the common case on + 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). + + Result is cached for the process lifetime. Import-safe — no heavy deps. + + See: NousResearch/hermes-agent#47111 """ global _container_detected if _container_detected is not None: @@ -476,10 +486,26 @@ def is_container() -> bool: if os.path.exists("/run/.containerenv"): _container_detected = True return True + # Kubernetes always injects this into pod containers; absent on hosts. + if os.environ.get("KUBERNETES_SERVICE_HOST"): + _container_detected = True + return True + _CGROUP_MARKERS = ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio") try: with open("/proc/1/cgroup", "r", encoding="utf-8") as f: cgroup = f.read() - if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup: + if any(marker in cgroup for marker in _CGROUP_MARKERS): + _container_detected = True + 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. + 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 except OSError: -
tests/test_hermes_constants.py@@ -139,13 +139,67 @@ def test_negative_case(self, monkeypatch, tmp_path): """Returns False on a regular Linux host.""" 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("12:memory:/\n") + mountinfo_file = tmp_path / "mountinfo" + mountinfo_file.write_text("22 21 0:20 / /sys rw shared:7 - sysfs sysfs rw\n") _real_open = builtins.open - monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw)) + + 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_kubernetes_env(self, monkeypatch): + """KUBERNETES_SERVICE_HOST env var triggers detection (k8s/k3s pod).""" + self._reset_cache(monkeypatch) + monkeypatch.setattr(os.path, "exists", lambda p: False) + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.43.0.1") + assert is_container() is True + + def test_detects_cgroup_kubepods(self, monkeypatch, tmp_path): + """/proc/1/cgroup containing 'kubepods' triggers detection.""" + 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("12:memory:/kubepods/besteffort/podabc\n") + _real_open = builtins.open + monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw)) + assert is_container() is True + + def test_detects_cgroup_v2_via_mountinfo(self, monkeypatch, tmp_path): + """cgroup v2 (0::/ only) falls back to containerd marker in mountinfo.""" + 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( + "1234 1233 0:42 /containerd/.../rootfs / rw - overlay overlay rw\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)
-
-
#105351 fix(agent): a host that merely runs containers is no longer classified as one (#58135, #48594, salvage #58141)
files:
hermes_constants.py,tests/test_hermes_constants.py -
#117591 fix(constants): scratch dir honours HERMES_HOME_MODE and managed-home permissions instead of forcing 0700 (#117347, salvage #117359)
files:
hermes_cli/config.py,hermes_constants.py,tests/hermes_cli/test_ensure_hermes_home_uid.py,tests/test_scratch_dir.py
10 PRs closed without merging
From the close comments: done elsewhere 4 · duplicate 2 · merged via another change 1 · superseded by another PR 1 · other 2
-
#44733 fix Kubernetes container detection · done elsewhere →
891c1d7,c5a3c0f,#117591,#47131 -
#44734 fix: Kubernetes containers · duplicate →
#44733,#44721 -
#44777 fix(constants): detect Kubernetes/k3s pods in is_container() (#44721) · duplicate →
#44733 -
#47144 fix(constants): detect containerd + cgroup v2 in is_container() (#47111) · done elsewhere →
#47111,#47131,#46290 - #48598 fix(constants): ignore host container runtime mounts · other
Show 5 more
- #48695 fix(constants): exclude host Docker storage from is_container (#48594) · other
-
#58141 fix(agent): scan only root mount in is_container() cgroup-v2 fallback (#58135) · merged via another change →
#105351,#48594 -
#58145 fix(cli): stop is_container() false-positive on hosts running containers · done elsewhere →
7b29f19,#105351,#58141 -
#65060 fix(constants): stop is_container() false positives on hosts that run containers (#65051) · done elsewhere →
7b29f19,#105351,#58141 -
#102169 fix(docker): don't classify a Docker host as a container · superseded by another PR →
#58141,#48594
Closed issues · 5
- Closed#44721Kubernetes/k3s pods are not detected as containers, so `hermes gateway start` reports unsupported platform
- Closed#47111is_container() misses containerd + cgroup v2 (k8s/k3s) → s6 gateway supervision silently disabled → gateway double-start crash loop
- Closed#48594Dashboard update controls hidden on systemd host with Docker/containerd running
- Closed#58135[Bug]: `is_container()` false-positives on hosts running Docker containers (containerd markers in mountinfo), making `home_mode: auto` subprocess HOME non-deterministic — breaks browser auto-launch with "Chrome not found"
- Closed#65051terminal.backend (docker AND ssh) silently forced to local - false "running inside a container" self-detection on bare VM hosts
How this cluster was grouped
-
Link evidence (
partly structural links): 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.12: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means 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