{"repo":"NousResearch/hermes-agent","public_id":"2305","display_label":"Docker environment detection, update handling, config loading","summary":"These items address overlapping concerns: correctly detecting Docker vs. non-Docker environments for update behavior, surfacing appropriate guidance instead of errors when updates are attempted inside containers, and loading platform/webhook configuration from config.yaml into the gateway and extra fields.","subsystem":"agent","counts":{"members":32,"open_issues":3,"open_issues_distinct":3,"open_prs":2,"closed_issues":8,"merged_prs":6,"attempt_prs":15},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2328},"resolution":{"state":"partially_resolved","by":"pr-2328,pr-35139,pr-37085,pr-109173"},"freshness":{"state":"verified","last_verified_at":"2026-09-12T15:42:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.05564516129032257},"fix_edges":[{"issue_number":2305,"pr_number":2328,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":10206,"pr_number":109173,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":20501,"pr_number":109173,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34347,"pr_number":36263,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":34347,"pr_number":37085,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34397,"pr_number":35139,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":35026,"pr_number":48188,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":35835,"pr_number":48188,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":2305,"title":"[Bug]: Webhook route docs/config mismatch","url":null,"state":"closed"},{"kind":"issue","number":7070,"title":"Consider git-free installation and version-based auto-update","url":null,"state":"open"},{"kind":"issue","number":10206,"title":"[Bug]: platforms.webhook.port in config.yaml does not work","url":null,"state":"closed"},{"kind":"issue","number":10290,"title":"[Feature]: Migrate  hermes agent to newer version of hermes agent repo","url":null,"state":"open"},{"kind":"issue","number":20501,"title":"platforms.api_server config values (port, host, key) silently ignored when not nested under 'extra'","url":null,"state":"closed"},{"kind":"issue","number":34347,"title":"WebUI \"Update\" button surfaces raw SystemExit when run in Docker context","url":null,"state":"closed"},{"kind":"issue","number":34397,"title":"[Bug]: Hermes dont discriminate between running in a docker container and being the docker image.","url":null,"state":"closed"},{"kind":"issue","number":35026,"title":"[Bug]: linux: update fails ","url":null,"state":"closed"},{"kind":"issue","number":35835,"title":"[Bug]: hermes update doesn't apply inside the Docker container.","url":null,"state":"closed"},{"kind":"issue","number":45792,"title":"[Bug]: Hermes Agent inside docker doesn't understand its environment","url":null,"state":"open"},{"kind":"issue","number":50620,"title":"[Bug]: Inside Docker Fails to Configuration sub component settings","url":null,"state":"closed"},{"kind":"pr","number":2307,"title":"fix(gateway): bridge webhook extra.routes from config.yaml to gateway config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2328","number":2328},{"kind":"pr","value":"#2328","number":2328}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":2308,"title":"fix:  Webhook route docs/config mismatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2328","number":2328}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2310,"title":"feat(config): add support for nested platform mapping in config.yaml","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":2328,"title":"fix(gateway): load platforms section from config.yaml for webhook routes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -455,11 +455,27 @@ def load_gateway_config() -> GatewayConfig:\n                     \"pair\",\n                 )\n \n-            # Bridge per-platform settings from config.yaml into gw_data\n+            # Merge platforms section from config.yaml into gw_data so that\n+            # nested keys like platforms.webhook.extra.routes are loaded.\n+            yaml_platforms = yaml_cfg.get(\"platforms\")\n             platforms_data = gw_data.setdefault(\"platforms\", {})\n             if not isinstance(platforms_data, dict):\n                 platforms_data = {}\n                 gw_data[\"platforms\"] = platforms_data\n+            if isinstance(yaml_platforms, dict):\n+                for plat_name, plat_block in yaml_platforms.items():\n+                    if not isinstance(plat_block, dict):\n+                        continue\n+                    existing = platforms_data.get(plat_name, {})\n+                    if not isinstance(existing, dict):\n+                        existing = {}\n+                    # Deep-merge extra dicts so gateway.json defaults survive\n+                    merged_extra = {**existing.get(\"extra\", {}), **plat_block.get(\"extra\", {})}\n+                    merged = {**existing, **plat_block}\n+                    if merged_extra:\n+                        merged[\"extra\"] = merged_extra\n+                    platforms_data[plat_name] = merged\n+                gw_data[\"platforms\"] = platforms_data\n             for plat in Platform:\n                 if plat == Platform.LOCAL:\n                     continue\n"]}]},{"kind":"pr","number":10211,"title":"fix(config): forward unknown platform keys into config.extra","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10453","number":10453}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":10219,"title":"fix(gateway): pass unknown platform config keys to extra","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10453","number":10453}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":10453,"title":"fix(gateway): preserve top-level platform config keys","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":20506,"title":"fix(gateway/config): merge top-level platform keys into extra (#20501)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#10453","number":10453},{"kind":"pr","value":"#10208","number":10208},{"kind":"pr","value":"#10453","number":10453},{"kind":"pr","value":"#10206","number":10206},{"kind":"pr","value":"#20501","number":20501}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":20528,"title":"fix: read top-level api_server config fields into extra","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21114,"title":"fix(gateway): honor top-level platform config extras","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20506","number":20506},{"kind":"pr","value":"#10453","number":10453}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":34426,"title":"fix(cli): scope docker install detection to the official image","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c1b2d0917","number":0},{"kind":"pr","value":"#35139","number":35139},{"kind":"pr","value":"#34397","number":34397}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34831,"title":"fix: guard dashboard update in Docker","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36263","number":36263},{"kind":"pr","value":"#34347","number":34347},{"kind":"pr","value":"#35835","number":35835}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35034,"title":"fix(cli): check .git before stamp file in detect_install_method","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4440d77bf32d6267775be5eba2189e1ebde0b5b5","number":0},{"kind":"pr","value":"#48188","number":48188}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35139,"title":"fix(cli): don't treat any container as the Docker image for updates","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","tests/hermes_cli/test_pip_install_detection.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -285,9 +285,22 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:\n     Resolution order:\n     1. Stamped ``~/.hermes/.install_method`` file (written by installers)\n     2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)\n-    3. Container detection (/.dockerenv, /run/.containerenv, cgroup)\n-    4. .git directory presence -> 'git'\n-    5. Fallback -> 'pip'\n+    3. .git directory presence -> 'git'\n+    4. Fallback -> 'pip'\n+\n+    Note: running inside a container is NOT treated as \"docker\" on its own.\n+    The two supported install paths both self-identify via the\n+    ``.install_method`` stamp (caught by step 1), so neither relies on\n+    container detection here:\n+      - the curl installer (scripts/install.sh, the README/website install\n+        command) git-clones the repo and stamps ``git``;\n+      - the published ``nousresearch/hermes-agent`` image stamps ``docker``\n+        at boot via ``docker/stage2-hook.sh``.\n+    An unsupported manual install dropped into a container (no stamp) was\n+    wrongly classified as the published image by bare container detection,\n+    so ``hermes update`` bailed with \"doesn't apply inside the Docker\n+    container\". Without that fallback such installs fall through to the\n+    ``.git``/pip checks and behave like any off-path install. See issue #34397.\n     \"\"\"\n     stamp = get_hermes_home() / \".install_method\"\n     try:","@@ -299,9 +312,6 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:\n     managed = get_managed_system()\n     if managed:\n         return managed.lower().replace(\" \", \"-\")\n-    from hermes_constants import is_container\n-    if is_container():\n-        return \"docker\"\n     if project_root is None:\n         project_root = Path(__file__).parent.parent.resolve()\n     if (project_root / \".git\").is_dir():"]},{"file":"tests/hermes_cli/test_pip_install_detection.py","hunks":["@@ -48,12 +48,32 @@ def test_stamp_file_takes_precedence(tmp_path):\n         assert detect_install_method(project_root=tmp_path) == \"docker\"\n \n \n-def test_docker_detected_via_dockerenv(tmp_path):\n+def test_container_without_stamp_is_not_docker(tmp_path):\n+    \"\"\"An unstamped install in a generic container must NOT be flagged as docker.\n+\n+    Regression for issue #34397. The two supported installs both stamp\n+    ``.install_method`` (the curl installer -> ``git``, covered by\n+    ``test_stamp_file_takes_precedence``; the published image -> ``docker``),\n+    so neither hits this path. An unsupported manual install dropped into a\n+    container has no stamp and was wrongly classified as the published Docker\n+    image, so ``hermes update`` refused to run. With a ``.git`` checkout it\n+    must resolve to ``git``.\n+    \"\"\"\n+    (tmp_path / \".git\").mkdir()\n     with patch(\"hermes_cli.config.get_managed_system\", return_value=None), \\\n          patch(\"hermes_cli.config.get_hermes_home\", return_value=tmp_path), \\\n          patch(\"hermes_constants.is_container\", return_value=True):\n         from hermes_cli.config import detect_install_method\n-        assert detect_install_method(project_root=tmp_path) == \"docker\"\n+        assert detect_install_method(project_root=tmp_path) == \"git\"\n+\n+\n+def test_container_pip_install_without_stamp_is_pip(tmp_path):\n+    \"\"\"Container + no .git + no stamp -> pip, not docker (issue #34397).\"\"\"\n+    with patch(\"hermes_cli.config.get_managed_system\", return_value=None), \\\n+         patch(\"hermes_cli.config.get_hermes_home\", return_value=tmp_path), \\\n+         patch(\"hermes_constants.is_container\", return_value=True):\n+        from hermes_cli.config import detect_install_method\n+        assert detect_install_method(project_root=tmp_path) == \"pip\"\n \n \n def test_recommended_update_command_docker():\n"]}]},{"kind":"pr","number":35339,"title":"fix(dashboard): guard update endpoint in Docker with structured guidance","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36263","number":36263},{"kind":"pr","value":"#34831","number":34831}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":36254,"title":"fix(config): ignore stale docker install-method stamp when .git exists (#35835)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4440d77bf32d6267775be5eba2189e1ebde0b5b5","number":0},{"kind":"pr","value":"#48188","number":48188}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":36263,"title":"fix(dashboard): guard update endpoint in Docker with structured guidance (salvage #34831)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py","tests/hermes_cli/test_web_server.py","web/src/lib/api.ts"]},{"kind":"pr","number":37085,"title":"fix(dashboard): surface Docker update guidance instead of generic failure (#34347)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["web/src/contexts/SystemActions.tsx"],"fix_hunks":[{"file":"web/src/contexts/SystemActions.tsx","hunks":["@@ -71,10 +71,28 @@ export function SystemActionsProvider({\n       try {\n         if (action === \"restart\") {\n           await api.restartGateway();\n+          setActiveAction(action);\n         } else {\n-          await api.updateHermes();\n+          const resp = await api.updateHermes();\n+          // In a Docker install the image is immutable, so `hermes update`\n+          // can't apply — the endpoint returns 200 with a structured\n+          // {ok:false, error:\"docker_update_unsupported\", message, update_command}\n+          // envelope instead of spawning the action (see #34347 / #36263).\n+          // Surface that guidance to the user rather than starting the poll,\n+          // which would otherwise report a generic \"failed (exit 1)\".\n+          if (!resp.ok && resp.error === \"docker_update_unsupported\") {\n+            const cmd = resp.update_command ? `  ${resp.update_command}` : \"\";\n+            setToast({\n+              type: \"success\",\n+              message:\n+                (resp.message ??\n+                  \"Updates don't apply inside Docker — re-pull the image instead.\") +\n+                cmd,\n+            });\n+            return;\n+          }\n+          setActiveAction(action);\n         }\n-        setActiveAction(action);\n       } catch (err) {\n         const detail = err instanceof Error ? err.message : String(err);\n         setToast({\n"]}]},{"kind":"pr","number":45811,"title":"fix(prompt): surface the Hermes config/state directory in environment hints","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":48188,"title":"fix(update): scope install-method stamp to the code tree, not $HERMES_HOME","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["Dockerfile","docker/stage2-hook.sh","hermes_cli/config.py","scripts/install.sh","tests/hermes_cli/test_pip_install_detection.py","tests/test_install_sh_install_method_stamp.py","tests/tools/test_dockerfile_immutable_install.py","tests/tools/test_stage2_hook_install_method_stamp.py"],"fix_hunks":[{"file":"Dockerfile","hunks":["@@ -206,9 +206,16 @@ USER root\n RUN mkdir -p /opt/hermes/bin && \\\n     cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \\\n     chmod 0755 /opt/hermes/bin/hermes && \\\n+    printf 'docker\\n' > /opt/hermes/.install_method && \\\n     chown -R root:root /opt/hermes && \\\n     chmod -R a+rX /opt/hermes && \\\n     chmod -R a-w /opt/hermes\n+# The ``.install_method`` stamp is baked next to the running code (the install\n+# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data\n+# volume that is commonly bind-mounted from the host and even shared with a\n+# host-side Desktop/CLI install; stamping it at boot used to clobber that\n+# host install's marker and wrongly block its ``hermes update``. A code-scoped\n+# stamp is read first by detect_install_method() and is immune to the share.\n # Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown\n # the data volume. Each supervised service then drops to the hermes user via\n # `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services"]},{"file":"docker/stage2-hook.sh","hunks":["@@ -291,13 +291,25 @@ as_hermes mkdir -p \\\n     \"$HERMES_HOME/pairing\" \\\n     \"$HERMES_HOME/platforms/pairing\"\n \n-# --- Install-method stamp (read by detect_install_method() in hermes status) ---\n-# Preserved from the tini-era entrypoint (PR #27843). Must be written as\n-# the hermes user so ownership matches the file's documented owner.\n-# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the\n-# same shell-metacharacter safety described above.\n-printf 'docker\\n' | as_hermes tee \"$HERMES_HOME/.install_method\" >/dev/null \\\n-    || true\n+# --- Install-method stamp ---\n+# The 'docker' stamp is baked into the immutable install tree at\n+# /opt/hermes/.install_method (see Dockerfile), NOT written here into\n+# $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.\n+#\n+# Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly\n+# bind-mounted from the host (~/.hermes:/opt/data) and sometimes shared with a\n+# host-side Desktop/CLI install. Stamping 'docker' here clobbered that host\n+# install's marker, so its in-app updater read 'docker' and refused to run\n+# 'hermes update'. To heal homes already poisoned by older images, remove a\n+# stale 'docker' stamp from $HERMES_HOME if one is present (the host install's\n+# own installer re-creates its code-scoped stamp; a genuine container relies on\n+# the baked /opt/hermes stamp, so deleting the data-dir copy is safe).\n+if [ -f \"$HERMES_HOME/.install_method\" ]; then\n+    stamped=\"$(tr -d '[:space:]' < \"$HERMES_HOME/.install_method\" 2>/dev/null || true)\"\n+    if [ \"$stamped\" = \"docker\" ]; then\n+        rm -f \"$HERMES_HOME/.install_method\" 2>/dev/null || true\n+    fi\n+fi\n \n # --- Seed config files (only on first boot) ---\n seed_one() {"]},{"file":"hermes_cli/config.py","hunks":["@@ -350,52 +350,124 @@ def get_managed_update_command() -> Optional[str]:\n     return None\n \n \n+def _install_method_project_root(project_root: Optional[Path] = None) -> Path:\n+    \"\"\"Resolve the directory that holds the *running code* (the install tree).\n+\n+    This is the parent of ``hermes_cli/`` — i.e. the git checkout for source\n+    installs, ``/opt/hermes`` inside the published image, the venv's\n+    site-packages root for pip installs. It is a property of the running\n+    interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp\n+    here is immune to two installs sharing one data directory.\n+    \"\"\"\n+    if project_root is not None:\n+        return project_root\n+    return Path(__file__).parent.parent.resolve()\n+\n+\n def detect_install_method(project_root: Optional[Path] = None) -> str:\n     \"\"\"Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.\n \n     Resolution order:\n-    1. Stamped ``~/.hermes/.install_method`` file (written by installers)\n-    2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)\n-    3. .git directory presence -> 'git'\n-    4. Fallback -> 'pip'\n+    1. Code-scoped stamp ``<install tree>/.install_method`` (next to the\n+       running code) — the authoritative marker.\n+    2. Legacy home-scoped stamp ``$HERMES_HOME/.install_method`` — read for\n+       backward compatibility, but a ``docker`` value is IGNORED when we are\n+       not actually running inside a container (see below).\n+    3. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)\n+    4. .git directory presence -> 'git'\n+    5. Fallback -> 'pip'\n+\n+    Why the stamp is code-scoped, not home-scoped (issue: shared ``~/.hermes``)\n+    --------------------------------------------------------------------------\n+    The install method describes *the binary that is running*, but\n+    ``$HERMES_HOME`` is a shared DATA directory — the Docker docs deliberately\n+    bind-mount it (``~/.hermes:/opt/data``) so config/sessions/memory persist\n+    and can be shared with a host-side Desktop/CLI install. When a\n+    containerised gateway and a host install share one ``$HERMES_HOME``, a\n+    home-scoped stamp is a single slot describing two different installs:\n+    the container stamps ``docker`` on every boot, the host install then reads\n+    ``docker`` and ``hermes update`` refuses to run (\"doesn't apply inside the\n+    Docker container\") even though the host binary is a perfectly updatable\n+    git/pip install. Scoping the stamp to the install tree gives each install\n+    its own truthful marker.\n+\n+    Self-healing for already-poisoned homes: a legacy ``docker`` value in the\n+    home-scoped stamp is only honoured when we are genuinely in a container.\n+    On a host install that read a contaminating ``docker`` stamp, we fall\n+    through to managed/.git/pip detection instead — so existing shared-home\n+    setups recover without the user touching anything.\n \n     Note: running inside a container is NOT treated as \"docker\" on its own.\n-    The two supported install paths both self-identify via the\n-    ``.install_method`` stamp (caught by step 1), so neither relies on\n-    container detection here:\n+    The supported installs self-identify via the code-scoped stamp:\n       - the curl installer (scripts/install.sh, the README/website install\n-        command) git-clones the repo and stamps ``git``;\n-      - the published ``nousresearch/hermes-agent`` image stamps ``docker``\n-        at boot via ``docker/stage2-hook.sh``.\n-    An unsupported manual install dropped into a container (no stamp) was\n-    wrongly classified as the published image by bare container detection,\n-    so ``hermes update`` bailed with \"doesn't apply inside the Docker\n-    container\". Without that fallback such installs fall through to the\n-    ``.git``/pip checks and behave like any off-path install. See issue #34397.\n+        command) git-clones the repo and stamps ``git`` next to the code;\n+      - the published ``nousresearch/hermes-agent`` image bakes a ``docker``\n+        stamp into ``/opt/hermes`` at build time.\n+    An unsupported manual install dropped into a container (no stamp) falls\n+    through to the ``.git``/pip checks and behaves like any off-path install.\n+    See issue #34397.\n     \"\"\"\n-    stamp = get_hermes_home() / \".install_method\"\n+    root = _install_method_project_root(project_root)\n+\n+    # 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.\n     try:\n-        method = stamp.read_text(encoding=\"utf-8\").strip().lower()\n+        method = (root / \".install_method\").read_text(encoding=\"utf-8\").strip().lower()\n         if method:\n             return method\n     except OSError:\n         pass\n+\n+    # 2. Legacy home-scoped stamp — back-compat. Ignore a ``docker`` value\n+    #    when we are not actually containerised: that is the signature of a\n+    #    host install whose shared $HERMES_HOME was stamped by a co-located\n+    #    container, and honouring it wrongly blocks ``hermes update``.\n+    try:\n+        method = (\n+            (get_hermes_home() / \".install_method\")\n+            .read_text(encoding=\"utf-8\")\n+            .strip()\n+            .lower()\n+        )\n+        if method and not (method == \"docker\" and not _running_in_container()):\n+            return method\n+    except OSError:\n+        pass\n+\n     managed = get_managed_system()\n     if managed:\n         return managed.lower().replace(\" \", \"-\")\n-    if project_root is None:\n-        project_root = Path(__file__).parent.parent.resolve()\n-    if (project_root / \".git\").is_dir():\n+    if (root / \".git\").is_dir():\n         return \"git\"\n     return \"pip\"\n \n \n-def stamp_install_method(method: str) -> None:\n-    \"\"\"Write the install method to ~/.hermes/.install_method.\"\"\"\n-    stamp = get_hermes_home() / \".install_method\"\n+def _running_in_container() -> bool:\n+    \"\"\"Thin wrapper around ``hermes_constants.is_container`` (import-safe).\"\"\"\n+    try:\n+        from hermes_constants import is_container\n+\n+        return is_container()\n+    except Exception:\n+        return False\n+\n+\n+def stamp_install_method(method: str, project_root: Optional[Path] = None) -> None:\n+    \"\"\"Write the install method next to the running code (code-scoped stamp).\n+\n+    The stamp lives in the install tree (``<install tree>/.install_method``),\n+    not in ``$HERMES_HOME``, so that two installs sharing one data directory\n+    do not overwrite each other's marker. See ``detect_install_method`` for\n+    the full rationale.\n+\n+    Best-effort: if the install tree is read-only (e.g. the immutable\n+    ``/opt/hermes`` in the published image, which instead bakes the stamp at\n+    build time) the write silently no-ops and detection falls back to its\n+    other signals.\n+    \"\"\"\n+    root = _install_method_project_root(project_root)\n     try:\n-        stamp.parent.mkdir(parents=True, exist_ok=True)\n-        stamp.write_text(method + \"\\n\", encoding=\"utf-8\")\n+        root.mkdir(parents=True, exist_ok=True)\n+        (root / \".install_method\").write_text(method + \"\\n\", encoding=\"utf-8\")\n     except OSError:\n         pass\n "]},{"file":"scripts/install.sh","hunks":["@@ -2732,7 +2732,12 @@ run_stage_body() {\n             detect_os\n             resolve_install_layout\n             print_success\n-            echo \"git\" > \"$HERMES_HOME/.install_method\"\n+            # Code-scoped stamp: write next to the install tree, not into\n+            # $HERMES_HOME. $HERMES_HOME is a shared data dir (it can be\n+            # bind-mounted into a Docker gateway too), so a stamp there gets\n+            # clobbered by the container's 'docker' stamp and wrongly blocks\n+            # 'hermes update' on this host install. See detect_install_method().\n+            echo \"git\" > \"$INSTALL_DIR/.install_method\"\n             ;;\n         *)\n             log_error \"Unknown stage: $stage\"","@@ -2811,7 +2816,12 @@ main() {\n \n     print_success\n \n-    echo \"git\" > \"$HERMES_HOME/.install_method\"\n+    # Code-scoped stamp: write next to the install tree, not into $HERMES_HOME.\n+    # $HERMES_HOME is a shared data dir (it can be bind-mounted into a Docker\n+    # gateway too), so a stamp there gets clobbered by the container's 'docker'\n+    # stamp and wrongly blocks 'hermes update' on this host install.\n+    # See detect_install_method().\n+    echo \"git\" > \"$INSTALL_DIR/.install_method\"\n }\n \n if [ \"$MANIFEST_MODE\" = true ]; then"]},{"file":"tests/hermes_cli/test_pip_install_detection.py","hunks":["@@ -48,6 +48,97 @@ def test_stamp_file_takes_precedence(tmp_path):\n         assert detect_install_method(project_root=tmp_path) == \"docker\"\n \n \n+def test_code_scoped_stamp_wins_over_home_stamp(tmp_path):\n+    \"\"\"The stamp next to the running code is authoritative over $HERMES_HOME.\n+\n+    Models a host git install whose $HERMES_HOME is shared with (and stamped\n+    'docker' by) a co-located container. The code-scoped stamp must win so the\n+    host install is correctly identified as 'git' and 'hermes update' works.\n+    \"\"\"\n+    code = tmp_path / \"code\"\n+    home = tmp_path / \"home\"\n+    code.mkdir()\n+    home.mkdir()\n+    (code / \".install_method\").write_text(\"git\\n\")\n+    (home / \".install_method\").write_text(\"docker\\n\")  # container contamination\n+    with patch(\"hermes_cli.config.get_managed_system\", return_value=None), \\\n+         patch(\"hermes_cli.config.get_hermes_home\", return_value=home):\n+        from hermes_cli.config import detect_install_method\n+        assert detect_install_method(project_root=code) == \"git\"\n+\n+\n+def test_home_docker_stamp_ignored_when_not_containerized(tmp_path):\n+    \"\"\"A 'docker' home stamp is ignored on a host (non-container) install.\n+\n+    Self-heal path for homes already poisoned by an older image that wrote\n+    'docker' into the shared $HERMES_HOME. With no code-scoped stamp, a host\n+    git checkout must fall through to '.git' detection rather than honour the\n+    contaminating 'docker' value and refuse to update.\n+    \"\"\"\n+    code = tmp_path / \"code\"\n+    home = tmp_path / \"home\"\n+    code.mkdir()\n+    home.mkdir()\n+    (code / \".git\").mkdir()\n+    (home / \".install_method\").write_text(\"docker\\n\")\n+    with patch(\"hermes_cli.config.get_managed_system\", return_value=None), \\\n+         patch(\"hermes_cli.config.get_hermes_home\", return_value=home), \\\n+         patch(\"hermes_cli.config._running_in_container\", return_value=False):\n+        from hermes_cli.config import detect_install_method\n+        assert detect_install_method(project_root=code) == \"git\"\n+\n+\n+def test_home_docker_stamp_honored_inside_container(tmp_path):\n+    \"\"\"A 'docker' home stamp is still honoured when genuinely containerized.\n+\n+    Back-compat: an older published image that only ever wrote the home-scoped\n+    stamp (no baked code stamp) must still resolve to 'docker' so the update\n+    path keeps directing the user to ``docker pull``.\n+    \"\"\"\n+    code = tmp_path / \"code\"\n+    home = tmp_path / \"home\"\n+    code.mkdir()\n+    home.mkdir()\n+    (home / \".install_method\").write_text(\"docker\\n\")\n+    with patch(\"hermes_cli.config.get_managed_system\", return_value=None), \\\n+         patch(\"hermes_cli.config.get_hermes_home\", return_value=home), \\\n+         patch(\"hermes_cli.config._running_in_container\", return_value=True):\n+        from hermes_cli.config import detect_install_method\n+        assert detect_install_method(project_root=code) == \"docker\"\n+\n+\n+def test_home_non_docker_stamp_still_honored_for_backcompat(tmp_path):\n+    \"\"\"Legacy non-'docker' home stamps (e.g. 'git') are still respected.\n+\n+    Only the 'docker' value carries the cross-contamination risk, so a host\n+    install that historically stamped 'git'/'pip' into $HERMES_HOME keeps\n+    resolving from there when no code-scoped stamp exists yet.\n+    \"\"\"\n+    code = tmp_path / \"code\"\n+    home = tmp_path / \"home\"\n+    code.mkdir()\n+    home.mkdir()\n+    (home / \".install_method\").write_text(\"git\\n\")\n+    with patch(\"hermes_cli.config.get_managed_system\", return_value=None), \\\n+         patch(\"hermes_cli.config.get_hermes_home\", return_value=home), \\\n+         patch(\"hermes_cli.config._running_in_container\", return_value=False):\n+        from hermes_cli.config import detect_install_method\n+        assert detect_install_method(project_root=code) == \"git\"\n+\n+\n+def test_stamp_install_method_writes_code_scoped(tmp_path):\n+    \"\"\"stamp_install_method writes next to the code, not into $HERMES_HOME.\"\"\"\n+    code = tmp_path / \"code\"\n+    home = tmp_path / \"home\"\n+    code.mkdir()\n+    home.mkdir()\n+    with patch(\"hermes_cli.config.get_hermes_home\", return_value=home):\n+        from hermes_cli.config import stamp_install_method\n+        stamp_install_method(\"pip\", project_root=code)\n+    assert (code / \".install_method\").read_text().strip() == \"pip\"\n+    assert not (home / \".install_method\").exists()\n+\n+\n def test_container_without_stamp_is_not_docker(tmp_path):\n     \"\"\"An unstamped install in a generic container must NOT be flagged as docker.\n "]},{"file":"tests/test_install_sh_install_method_stamp.py","hunks":["@@ -0,0 +1,40 @@\n+\"\"\"Contract test: install.sh stamps the install method next to the code tree\n+($INSTALL_DIR), not into the shared $HERMES_HOME.\n+\n+Background (shared-$HERMES_HOME bug)\n+------------------------------------\n+$HERMES_HOME is a data directory users frequently bind-mount into a Docker\n+gateway as well (``~/.hermes:/opt/data``). The published image stamps 'docker'\n+there on boot, so if install.sh had written its 'git' marker into the same\n+$HERMES_HOME the two installs would fight over one slot — and the container,\n+booting last, would win and wrongly make the host install look like 'docker'\n+(blocking ``hermes update``).\n+\n+The fix: detect_install_method() reads a CODE-scoped stamp first, and the\n+installer writes ``git`` into $INSTALL_DIR (the git checkout, e.g.\n+``~/.hermes/hermes-agent``), which is unique to this install and immune to the\n+shared data dir.\n+\"\"\"\n+from __future__ import annotations\n+\n+import re\n+from pathlib import Path\n+\n+REPO_ROOT = Path(__file__).resolve().parent.parent\n+INSTALL_SH = REPO_ROOT / \"scripts\" / \"install.sh\"\n+\n+\n+def test_install_sh_stamps_code_tree_not_home() -> None:\n+    text = INSTALL_SH.read_text()\n+\n+    # Stamps the code tree.\n+    assert text.count('echo \"git\" > \"$INSTALL_DIR/.install_method\"') >= 1, (\n+        \"install.sh must stamp $INSTALL_DIR/.install_method (code-scoped)\"\n+    )\n+\n+    # Never stamps the shared data dir.\n+    assert not re.search(r'>\\s*\"\\$HERMES_HOME/\\.install_method\"', text), (\n+        \"install.sh must not stamp $HERMES_HOME/.install_method — that data \"\n+        \"dir may be shared with a Docker gateway whose 'docker' stamp would \"\n+        \"clobber it and block host-side `hermes update`\"\n+    )"]},{"file":"tests/tools/test_dockerfile_immutable_install.py","hunks":["@@ -59,3 +59,28 @@ def test_dockerfile_does_not_chown_install_trees_to_hermes() -> None:\n             \"runtime install trees under /opt/hermes must stay immutable; \"\n             f\"found forbidden pattern {pattern!r}\"\n         )\n+\n+\n+def test_dockerfile_bakes_code_scoped_install_method_stamp() -> None:\n+    \"\"\"The 'docker' install-method stamp is baked next to the code.\n+\n+    detect_install_method() reads the code-scoped stamp\n+    (/opt/hermes/.install_method) first; baking it at build time keeps the\n+    published image self-identifying as 'docker' WITHOUT writing into the\n+    shared $HERMES_HOME data volume (which a host install may also use).\n+    It must live inside the immutable block so the runtime user can't alter it.\n+    \"\"\"\n+    text = _dockerfile_text()\n+    assert \"printf 'docker\\\\n' > /opt/hermes/.install_method\" in text\n+\n+    immutable_block = re.search(\n+        r\"RUN mkdir -p /opt/hermes/bin && \\\\\\n\"\n+        r\"(?:.*\\\\\\n)+?\"\n+        r\"\\s+chmod -R a-w /opt/hermes\",\n+        text,\n+    )\n+    assert immutable_block, \"immutable block must exist\"\n+    assert \".install_method\" in immutable_block.group(0), (\n+        \"the code-scoped install-method stamp must be baked inside the \"\n+        \"immutable /opt/hermes block\"\n+    )"]},{"file":"tests/tools/test_stage2_hook_install_method_stamp.py","hunks":["@@ -0,0 +1,61 @@\n+\"\"\"Contract test: the s6-overlay stage2 hook must NOT stamp the install method\n+into the shared $HERMES_HOME, and must heal a stale 'docker' stamp left there\n+by older images.\n+\n+Background (shared-$HERMES_HOME bug)\n+------------------------------------\n+$HERMES_HOME (/opt/data) is a DATA volume that users commonly bind-mount from\n+the host (``~/.hermes:/opt/data``) and sometimes share with a host-side\n+Desktop/CLI install. Older images wrote ``printf 'docker' > $HERMES_HOME/.install_method``\n+at boot, which clobbered the host install's own marker — so the host's in-app\n+updater read 'docker' and refused to run ``hermes update`` (\"doesn't apply\n+inside the Docker container\").\n+\n+The fix scopes the stamp to the install tree (baked at\n+``/opt/hermes/.install_method`` in the Dockerfile, read first by\n+``detect_install_method``). stage2 must therefore:\n+\n+  * NOT write the 'docker' stamp into $HERMES_HOME any more, and\n+  * proactively remove a stale 'docker' stamp from $HERMES_HOME so homes\n+    already poisoned by an older image self-heal on the next boot.\n+\"\"\"\n+from __future__ import annotations\n+\n+import re\n+from pathlib import Path\n+\n+import pytest\n+\n+REPO_ROOT = Path(__file__).resolve().parents[2]\n+STAGE2_HOOK = REPO_ROOT / \"docker\" / \"stage2-hook.sh\"\n+\n+\n[redacted:email](scope=\"module\")\n+def stage2_text() -> str:\n+    if not STAGE2_HOOK.exists():\n+        pytest.skip(\"docker/stage2-hook.sh not present in this checkout\")\n+    return STAGE2_HOOK.read_text()\n+\n+\n+def test_stage2_does_not_write_install_method_into_home(stage2_text: str) -> None:\n+    # No write/tee of the home-scoped install-method stamp anywhere.\n+    assert not re.search(\n+        r\"(tee|>)\\s*\\\"?\\$HERMES_HOME/\\.install_method\", stage2_text\n+    ), (\n+        \"stage2 must not stamp $HERMES_HOME/.install_method — that data dir \"\n+        \"may be shared with a host install whose marker would be clobbered\"\n+    )\n+\n+\n+def test_stage2_heals_stale_docker_home_stamp(stage2_text: str) -> None:\n+    # It must remove a stale 'docker' stamp from $HERMES_HOME so already\n+    # poisoned shared homes recover.\n+    assert 'rm -f \"$HERMES_HOME/.install_method\"' in stage2_text, (\n+        \"stage2 must remove a stale 'docker' stamp from $HERMES_HOME to heal \"\n+        \"homes poisoned by older images\"\n+    )\n+    # The removal must be guarded on the value being 'docker' so we never\n+    # delete a legitimately-different stamp a user/host install put there.\n+    assert re.search(r'\\[\\s*\"\\$stamped\"\\s*=\\s*\"docker\"\\s*\\]', stage2_text), (\n+        \"the stale-stamp removal must be guarded on the value == 'docker'\"\n+    )\n"]}]},{"kind":"pr","number":109173,"title":"Top-level platform keys like platforms.webhook.port reach the adapter (#10206, #20501)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/config_loader.py","tests/gateway/test_config.py","website/docs/user-guide/messaging/webhooks.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}