{"repo":"NousResearch/hermes-agent","public_id":"49196","display_label":"Dashboard container role detection under s6-overlay v3","summary":"These issues and PRs concern a bug where dashboard container role detection reads the wrong PID under s6-overlay v3, causing unintended gateway reconciliation. The work involves fixing the detection logic to correctly identify the dashboard role in that environment.","subsystem":"dashboard","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50600,"merged_at":"2026-06-22T05:35:39+00:00"},"resolution":{"state":"resolved","by":"pr-50600"},"freshness":{"state":"verified","last_verified_at":"2026-06-22T05:35:39+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":49196,"pr_number":50600,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-22T05:35:39+00:00"}],"members":[{"kind":"issue","number":49196,"title":"[Bug]: Dashboard container starts per-profile gateways — _is_dashboard_container() role detection reads wrong PID under s6-overlay v3","url":null,"state":"closed"},{"kind":"pr","number":49238,"title":"fix(container): detect dashboard role under s6-overlay v3 (#49196)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6202fdfc3","number":0},{"kind":"pr","value":"#50600","number":50600}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50600,"title":"fix(container): detect dashboard role under s6-overlay v3 (#49196)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/container_boot.py","tests/hermes_cli/test_container_boot.py"],"fix_hunks":[{"file":"hermes_cli/container_boot.py","hunks":["@@ -199,28 +199,89 @@ def _maybe_migrate_legacy_gateway_run_state(\n \n \n def _read_container_argv() -> tuple[str, ...]:\n-    \"\"\"Best-effort read of the container PID 1 argv.\"\"\"\n+    \"\"\"Best-effort read of the container's main program argv.\n+\n+    Under s6-overlay v2, PID 1 is ``/init`` and its argv contains the\n+    ``main-wrapper.sh`` path.  Under s6-overlay v3, PID 1 is\n+    ``s6-svscan`` and the actual command (``rc.init top main-wrapper.sh\n+    ...``) lives on a different PID.  We try PID 1 first (fast path,\n+    covers v2 and pre-s6 images), then fall back to scanning\n+    ``/proc/*/cmdline`` for a process whose argv contains\n+    ``main-wrapper.sh`` (the rc.init-launched PID in v3).\n+    \"\"\"\n+    # Fast path: PID 1 is the command itself (s6-overlay v2 / tini).\n     try:\n         raw = Path(\"/proc/1/cmdline\").read_bytes()\n+        argv = tuple(\n+            part.decode(\"utf-8\", \"replace\") for part in raw.split(b\"\\0\") if part\n+        )\n+        if any(\"main-wrapper.sh\" in part for part in argv):\n+            return argv\n+    except OSError:\n+        pass\n+\n+    # Slow path: s6-overlay v3 — PID 1 is s6-svscan; find the\n+    # rc.init-launched process whose argv contains main-wrapper.sh.\n+    try:\n+        proc_dir = Path(\"/proc\")\n+        for entry in proc_dir.iterdir():\n+            if not entry.name.isdigit():\n+                continue\n+            try:\n+                raw = (entry / \"cmdline\").read_bytes()\n+            except OSError:\n+                continue\n+            argv = tuple(\n+                part.decode(\"utf-8\", \"replace\")\n+                for part in raw.split(b\"\\0\")\n+                if part\n+            )\n+            if any(\"main-wrapper.sh\" in part for part in argv):\n+                return argv\n     except OSError:\n-        return ()\n-    return tuple(part.decode(\"utf-8\", \"replace\") for part in raw.split(b\"\\0\") if part)\n+        pass\n \n+    return ()\n \n-def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]:\n-    \"\"\"Strip the s6/wrapper prefix off PID 1 argv, leaving the hermes args.\n \n-    The container PID 1 argv looks like\n-    ``/init /opt/hermes/docker/main-wrapper.sh <subcommand> [args...]`` and\n-    the wrapper re-execs ``hermes <subcommand>``. Peel ``init`` →\n-    ``main-wrapper.sh`` → ``hermes`` so callers can match on the bare\n-    subcommand. Shared by the legacy-gateway and dashboard role detectors.\n+def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]:\n+    \"\"\"Strip the s6/wrapper prefix off the container argv, leaving the hermes args.\n+\n+    Two container-command argv shapes are handled:\n+\n+    * **s6-overlay v2 / tini:** PID 1 argv is\n+      ``/init /opt/hermes/docker/main-wrapper.sh <subcommand> [args...]``.\n+    * **s6-overlay v3:** PID 1 is ``s6-svscan`` and the command lives on the\n+      rc.init-launched process as ``/bin/sh -e\n+      /run/s6/basedir/scripts/rc.init top /opt/hermes/docker/main-wrapper.sh\n+      <subcommand> [args...]`` (see :func:`_read_container_argv`).\n+\n+    Rather than peel each leading token positionally (which silently breaks\n+    the moment s6 changes its launcher shape again — exactly what happened\n+    in the v2→v3 bump), drop everything up to and including the\n+    ``main-wrapper.sh`` token: that wrapper path is the stable boundary the\n+    image owns, and the subcommand always follows it. Pre-s6 / direct\n+    ``hermes`` invocations carry no wrapper, so fall back to peeling a bare\n+    ``init`` prefix. The wrapper re-execs ``hermes <subcommand>``, so an\n+    explicit leading ``hermes`` is peeled too. Shared by the legacy-gateway\n+    and dashboard role detectors.\n     \"\"\"\n     args = list(argv)\n-    if args and Path(args[0]).name == \"init\":\n-        args = args[1:]\n-    if args and args[0].endswith(\"main-wrapper.sh\"):\n+\n+    # Preferred boundary: everything through main-wrapper.sh is launcher\n+    # prefix. Covers s6-overlay v2 (`/init …main-wrapper.sh …`) and v3\n+    # (`/bin/sh -e …rc.init top …main-wrapper.sh …`) with one rule.\n+    wrapper_idx = next(\n+        (i for i, a in enumerate(args) if a.endswith(\"main-wrapper.sh\")),\n+        None,\n+    )\n+    if wrapper_idx is not None:\n+        args = args[wrapper_idx + 1 :]\n+    elif args and Path(args[0]).name == \"init\":\n+        # Defensive: an `init` prefix with no wrapper token in argv.\n         args = args[1:]\n+\n+    # The wrapper re-execs `hermes <subcommand>`; peel an explicit hermes.\n     if args and Path(args[0]).name == \"hermes\":\n         args = args[1:]\n     return args"]},{"file":"tests/hermes_cli/test_container_boot.py","hunks":["@@ -25,6 +25,29 @@\n # ---------------------------------------------------------------------------\n \n \n[redacted:email](autouse=True)\n+def _hermetic_container_argv(monkeypatch: pytest.MonkeyPatch) -> None:\n+    \"\"\"Default ``_read_container_argv()`` to empty for the whole module.\n+\n+    ``_read_container_argv()`` walks the entire ``/proc`` table looking for\n+    a process whose argv contains ``main-wrapper.sh`` (the s6-overlay v3\n+    fallback). On a host that is *also* running hermes containers, those\n+    containers' ``main-wrapper.sh`` processes are visible in the host's\n+    ``/proc`` (shared PID view), so the scan would pick up a foreign\n+    ``gateway run`` argv and make ``_maybe_migrate_legacy_gateway_run_state``\n+    synthesize ``running`` state — flaking any test that reconciles without\n+    injecting ``container_argv``. Inside the real container ``/proc`` is the\n+    container's own PID namespace, so production is unaffected; this fixture\n+    just makes the unit suite hermetic. Tests that need a specific argv\n+    either pass ``container_argv=`` to ``reconcile_profile_gateways`` or\n+    monkeypatch ``_read_container_argv`` themselves (both override this).\n+    \"\"\"\n+    monkeypatch.setattr(\n+        \"hermes_cli.container_boot._read_container_argv\",\n+        lambda: (),\n+    )\n+\n+\n def _make_profile(\n     hermes_home: Path,\n     name: str,","@@ -733,6 +756,24 @@ def test_profiles_default_subdir_is_skipped_with_warning(\n         ),\n         # Wrapper that kept the explicit `hermes` argv0.\n         (\"/init\", \"/opt/hermes/docker/main-wrapper.sh\", \"hermes\", \"dashboard\"),\n+        # s6-overlay v3: PID 1 is s6-svscan, so the role is read off the\n+        # rc.init-launched process whose argv is\n+        # `/bin/sh -e .../rc.init top .../main-wrapper.sh dashboard ...`.\n+        # This is the exact shape that regressed in issue #49196.\n+        (\n+            \"/bin/sh\",\n+            \"-e\",\n+            \"/run/s6/basedir/scripts/rc.init\",\n+            \"top\",\n+            \"/opt/hermes/docker/main-wrapper.sh\",\n+            \"dashboard\",\n+            \"--host\",\n+            \"0.0.0.0\",\n+            \"--port\",\n+            \"9119\",\n+            \"--no-open\",\n+            \"--insecure\",\n+        ),\n     ],\n )\n def test_is_dashboard_container_true_for_dashboard_argv(","@@ -756,6 +797,17 @@ def test_is_dashboard_container_true_for_dashboard_argv(\n         # we key on is the SUBCOMMAND, and `gateway run -p dashboard` is a\n         # gateway container.\n         (\"gateway\", \"run\", \"-p\", \"dashboard\"),\n+        # s6-overlay v3 gateway container — the rc.init-launched argv for a\n+        # gateway role must still read as non-dashboard (issue #49196 shape).\n+        (\n+            \"/bin/sh\",\n+            \"-e\",\n+            \"/run/s6/basedir/scripts/rc.init\",\n+            \"top\",\n+            \"/opt/hermes/docker/main-wrapper.sh\",\n+            \"gateway\",\n+            \"run\",\n+        ),\n     ],\n )\n def test_is_dashboard_container_false_for_non_dashboard_argv(","@@ -798,6 +850,54 @@ def test_main_skips_reconcile_in_dashboard_container(\n     assert \"skipping (dashboard container\" in capsys.readouterr().out\n \n \n+def test_main_skips_reconcile_in_dashboard_container_s6v3(\n+    tmp_path: Path,\n+    monkeypatch: pytest.MonkeyPatch,\n+    capsys: pytest.CaptureFixture[str],\n+) -> None:\n+    \"\"\"The dashboard skip must fire under the s6-overlay v3 argv shape.\n+\n+    Regression test for issue #49196: under s6-overlay v3 the container\n+    command is read off the rc.init-launched process, whose argv is\n+    ``/bin/sh -e .../rc.init top .../main-wrapper.sh dashboard ...`` — not a\n+    bare ``/init`` prefix. Before the fix, the prefix-strip left ``/bin/sh``\n+    at args[0], so the role read as non-dashboard, the dashboard container\n+    reconciled, and it started its own gateway-default (dual Telegram\n+    getUpdates 409). Asserting the slot is absent proves the skip fires.\n+    \"\"\"\n+    from hermes_cli import container_boot\n+\n+    scandir = tmp_path / \"run-service\"; scandir.mkdir()\n+    _make_profile(tmp_path, \"worker\", state=\"running\")\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    monkeypatch.setenv(\"S6_PROFILE_GATEWAY_SCANDIR\", str(scandir))\n+    monkeypatch.setattr(\n+        container_boot,\n+        \"_read_container_argv\",\n+        lambda: (\n+            \"/bin/sh\",\n+            \"-e\",\n+            \"/run/s6/basedir/scripts/rc.init\",\n+            \"top\",\n+            \"/opt/hermes/docker/main-wrapper.sh\",\n+            \"dashboard\",\n+            \"--host\",\n+            \"0.0.0.0\",\n+            \"--port\",\n+            \"9119\",\n+            \"--no-open\",\n+            \"--insecure\",\n+        ),\n+    )\n+\n+    rc = container_boot.main()\n+\n+    assert rc == 0\n+    assert not (scandir / \"gateway-worker\").exists()\n+    assert not (scandir / \"gateway-default\").exists()\n+    assert \"skipping (dashboard container\" in capsys.readouterr().out\n+\n+\n def test_main_reconciles_in_gateway_container(\n     tmp_path: Path,\n     monkeypatch: pytest.MonkeyPatch,\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}