{"repo":"NousResearch/hermes-agent","public_id":"44721","display_label":"is_container() detection accuracy: false positives and misses","summary":"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.","subsystem":"agent","counts":{"members":18,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":3,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":47131},"resolution":{"state":"partially_resolved","by":"pr-47131,pr-105351"},"freshness":{"state":"verified","last_verified_at":"2026-09-20T23:32:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12287581699346403},"fix_edges":[{"issue_number":44721,"pr_number":47131,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":44721,"pr_number":117591,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":47111,"pr_number":47131,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":48594,"pr_number":105351,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":58135,"pr_number":105351,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":65051,"pr_number":105351,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":117347,"pr_number":117591,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":44721,"title":"Kubernetes/k3s pods are not detected as containers, so `hermes gateway start` reports unsupported platform","url":null,"state":"closed"},{"kind":"issue","number":47111,"title":"is_container() misses containerd + cgroup v2 (k8s/k3s) → s6 gateway supervision silently disabled → gateway double-start crash loop","url":null,"state":"closed"},{"kind":"issue","number":48594,"title":"Dashboard update controls hidden on systemd host with Docker/containerd running","url":null,"state":"closed"},{"kind":"issue","number":58135,"title":"[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\"","url":null,"state":"closed"},{"kind":"issue","number":65051,"title":"terminal.backend (docker AND ssh) silently forced to local - false \"running inside a container\" self-detection on bare VM hosts","url":null,"state":"closed"},{"kind":"pr","number":44733,"title":"fix Kubernetes container detection","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"891c1d79c67c5240a8247a92adb2bb12c4f326e0","number":0},{"kind":"commit","value":"c5a3c0f8767a","number":0},{"kind":"pr","value":"#117591","number":117591},{"kind":"pr","value":"#47131","number":47131}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":44734,"title":"fix: Kubernetes containers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44733","number":44733},{"kind":"pr","value":"#44733","number":44733},{"kind":"pr","value":"#44721","number":44721},{"kind":"pr","value":"#44733","number":44733}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":44777,"title":"fix(constants): detect Kubernetes/k3s pods in is_container() (#44721)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44733","number":44733}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":47131,"title":"fix(cli): detect containerd/CRI cgroup-v2 containers in is_container()","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_constants.py","tests/test_hermes_constants.py"],"fix_hunks":[{"file":"hermes_constants.py","hunks":["@@ -461,11 +461,21 @@ def is_wsl() -> bool:\n \n \n def is_container() -> bool:\n-    \"\"\"Return True when running inside a Docker/Podman container.\n+    \"\"\"Return True when running inside a container.\n \n-    Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman),\n-    and ``/proc/1/cgroup`` for container runtime markers.  Result is\n-    cached for the process lifetime.  Import-safe — no heavy deps.\n+    Recognizes Docker (``/.dockerenv``), Podman (``/run/.containerenv``),\n+    and — via ``/proc/1/cgroup`` — the docker/podman/lxc cgroup-v1 markers.\n+\n+    cgroup v2 collapses ``/proc/1/cgroup`` to a single ``0::/`` line with no\n+    runtime marker, so containerd/CRI-O runtimes (the common case on\n+    Kubernetes/k3s) were previously missed. To cover those, also check:\n+      * ``KUBERNETES_SERVICE_HOST`` env var — set in every Kubernetes pod.\n+      * ``kubepods`` / ``containerd`` / ``crio`` markers in ``/proc/1/cgroup``.\n+      * the same markers in ``/proc/self/mountinfo`` (cgroup-v2 fallback).\n+\n+    Result is cached for the process lifetime.  Import-safe — no heavy deps.\n+\n+    See: NousResearch/hermes-agent#47111\n     \"\"\"\n     global _container_detected\n     if _container_detected is not None:","@@ -476,10 +486,26 @@ def is_container() -> bool:\n     if os.path.exists(\"/run/.containerenv\"):\n         _container_detected = True\n         return True\n+    # Kubernetes always injects this into pod containers; absent on hosts.\n+    if os.environ.get(\"KUBERNETES_SERVICE_HOST\"):\n+        _container_detected = True\n+        return True\n+    _CGROUP_MARKERS = (\"docker\", \"podman\", \"/lxc/\", \"kubepods\", \"containerd\", \"crio\")\n     try:\n         with open(\"/proc/1/cgroup\", \"r\", encoding=\"utf-8\") as f:\n             cgroup = f.read()\n-            if \"docker\" in cgroup or \"podman\" in cgroup or \"/lxc/\" in cgroup:\n+            if any(marker in cgroup for marker in _CGROUP_MARKERS):\n+                _container_detected = True\n+                return True\n+    except OSError:\n+        pass\n+    # cgroup v2: /proc/1/cgroup is just \"0::/\" with no marker. The container\n+    # runtime still shows up in the mount table (overlay rootfs, runtime mount\n+    # paths), so scan mountinfo as a last resort.\n+    try:\n+        with open(\"/proc/self/mountinfo\", \"r\", encoding=\"utf-8\") as f:\n+            mountinfo = f.read()\n+            if any(marker in mountinfo for marker in (\"kubepods\", \"containerd\", \"crio\")):\n                 _container_detected = True\n                 return True\n     except OSError:"]},{"file":"tests/test_hermes_constants.py","hunks":["@@ -139,13 +139,67 @@ def test_negative_case(self, monkeypatch, tmp_path):\n         \"\"\"Returns False on a regular Linux host.\"\"\"\n         import builtins\n         self._reset_cache(monkeypatch)\n+        monkeypatch.delenv(\"KUBERNETES_SERVICE_HOST\", raising=False)\n         monkeypatch.setattr(os.path, \"exists\", lambda p: False)\n         cgroup_file = tmp_path / \"cgroup\"\n         cgroup_file.write_text(\"12:memory:/\\n\")\n+        mountinfo_file = tmp_path / \"mountinfo\"\n+        mountinfo_file.write_text(\"22 21 0:20 / /sys rw shared:7 - sysfs sysfs rw\\n\")\n         _real_open = builtins.open\n-        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))\n+\n+        def _fake_open(p, *a, **kw):\n+            if p == \"/proc/1/cgroup\":\n+                return _real_open(str(cgroup_file), *a, **kw)\n+            if p == \"/proc/self/mountinfo\":\n+                return _real_open(str(mountinfo_file), *a, **kw)\n+            return _real_open(p, *a, **kw)\n+\n+        monkeypatch.setattr(\"builtins.open\", _fake_open)\n         assert is_container() is False\n \n+    def test_detects_kubernetes_env(self, monkeypatch):\n+        \"\"\"KUBERNETES_SERVICE_HOST env var triggers detection (k8s/k3s pod).\"\"\"\n+        self._reset_cache(monkeypatch)\n+        monkeypatch.setattr(os.path, \"exists\", lambda p: False)\n+        monkeypatch.setenv(\"KUBERNETES_SERVICE_HOST\", \"10.43.0.1\")\n+        assert is_container() is True\n+\n+    def test_detects_cgroup_kubepods(self, monkeypatch, tmp_path):\n+        \"\"\"/proc/1/cgroup containing 'kubepods' triggers detection.\"\"\"\n+        import builtins\n+        self._reset_cache(monkeypatch)\n+        monkeypatch.delenv(\"KUBERNETES_SERVICE_HOST\", raising=False)\n+        monkeypatch.setattr(os.path, \"exists\", lambda p: False)\n+        cgroup_file = tmp_path / \"cgroup\"\n+        cgroup_file.write_text(\"12:memory:/kubepods/besteffort/podabc\\n\")\n+        _real_open = builtins.open\n+        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))\n+        assert is_container() is True\n+\n+    def test_detects_cgroup_v2_via_mountinfo(self, monkeypatch, tmp_path):\n+        \"\"\"cgroup v2 (0::/ only) falls back to containerd marker in mountinfo.\"\"\"\n+        import builtins\n+        self._reset_cache(monkeypatch)\n+        monkeypatch.delenv(\"KUBERNETES_SERVICE_HOST\", raising=False)\n+        monkeypatch.setattr(os.path, \"exists\", lambda p: False)\n+        cgroup_file = tmp_path / \"cgroup\"\n+        cgroup_file.write_text(\"0::/\\n\")  # cgroup v2 — no runtime marker\n+        mountinfo_file = tmp_path / \"mountinfo\"\n+        mountinfo_file.write_text(\n+            \"1234 1233 0:42 /containerd/.../rootfs / rw - overlay overlay rw\\n\"\n+        )\n+        _real_open = builtins.open\n+\n+        def _fake_open(p, *a, **kw):\n+            if p == \"/proc/1/cgroup\":\n+                return _real_open(str(cgroup_file), *a, **kw)\n+            if p == \"/proc/self/mountinfo\":\n+                return _real_open(str(mountinfo_file), *a, **kw)\n+            return _real_open(p, *a, **kw)\n+\n+        monkeypatch.setattr(\"builtins.open\", _fake_open)\n+        assert is_container() is True\n+\n     def test_caches_result(self, monkeypatch):\n         \"\"\"Second call uses cached value without re-probing.\"\"\"\n         monkeypatch.setattr(hermes_constants, \"_container_detected\", True)\n"]}]},{"kind":"pr","number":47144,"title":"fix(constants): detect containerd + cgroup v2 in is_container() (#47111)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47111","number":47111},{"kind":"pr","value":"#47131","number":47131},{"kind":"pr","value":"#47131","number":47131},{"kind":"pr","value":"#47111","number":47111},{"kind":"pr","value":"#46290","number":46290},{"kind":"pr","value":"#47131","number":47131}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":48598,"title":"fix(constants): ignore host container runtime mounts","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":48695,"title":"fix(constants): exclude host Docker storage from is_container (#48594)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":58141,"title":"fix(agent): scan only root mount in is_container() cgroup-v2 fallback (#58135)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#105351","number":105351},{"kind":"pr","value":"#48594","number":48594},{"kind":"pr","value":"#105351","number":105351}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58145,"title":"fix(cli): stop is_container() false-positive on hosts running containers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7b29f19146","number":0},{"kind":"pr","value":"#105351","number":105351},{"kind":"pr","value":"#58141","number":58141}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":65060,"title":"fix(constants): stop is_container() false positives on hosts that run containers (#65051)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7b29f19146","number":0},{"kind":"pr","value":"#105351","number":105351},{"kind":"pr","value":"#58141","number":58141}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":102169,"title":"fix(docker): don't classify a Docker host as a container","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58141","number":58141},{"kind":"pr","value":"#48594","number":48594}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":105351,"title":"fix(agent): a host that merely runs containers is no longer classified as one (#58135, #48594, salvage #58141)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_constants.py","tests/test_hermes_constants.py"]},{"kind":"pr","number":117591,"title":"fix(constants): scratch dir honours HERMES_HOME_MODE and managed-home permissions instead of forcing 0700 (#117347, salvage #117359)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","hermes_constants.py","tests/hermes_cli/test_ensure_hermes_home_uid.py","tests/test_scratch_dir.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}