{"repo":"NousResearch/hermes-agent","public_id":"49561","display_label":"Bridge install failures in read-only/container environments","summary":"These issues and fixes address failures installing WhatsApp and iMessage bridges when running in Docker, Podman, or other environments with read-only or rootless filesystem constraints. Work centers on writable directory resolution, HERMES_HOME handling, and npm permission compatibility.","subsystem":"environments","counts":{"members":10,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":73864,"merged_at":"2026-07-29T05:41:33+00:00"},"resolution":{"state":"partially_resolved","by":"pr-49839"},"freshness":{"state":"verified","last_verified_at":"2026-07-29T05:41:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.208},"fix_edges":[{"issue_number":49561,"pr_number":49839,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T00:05:28+00:00"},{"issue_number":62975,"pr_number":73864,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-29T05:41:33+00:00"}],"members":[{"kind":"issue","number":49561,"title":"[Bug]: Regression - WhatsApp bridge cannot be installed","url":null,"state":"closed"},{"kind":"issue","number":62975,"title":"[Bug]: Node sidecar dep (NPM) error on Podman install","url":null,"state":"open"},{"kind":"pr","number":49584,"title":"fix(whatsapp): use writable HERMES_HOME for bridge in Docker (fix #49561)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49557","number":49557},{"kind":"pr","value":"#49599","number":49599},{"kind":"pr","value":"#49561","number":49561}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":49599,"title":"fix(whatsapp): install bridge into a writable dir on read-only installs (fix #49561)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49561","number":49561},{"kind":"pr","value":"#49584","number":49584},{"kind":"pr","value":"#49569","number":49569}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":49654,"title":"fix(whatsapp): resolve bridge dir with HERMES_HOME mirror (fix #49561)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"491579fa05","number":0},{"kind":"pr","value":"#49839","number":49839}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":49839,"title":"fix(whatsapp): resolve bridge dir with HERMES_HOME mirror in read-only Docker (fix #49561)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/whatsapp_common.py","hermes_cli/main.py","plugins/platforms/whatsapp/adapter.py","scripts/release.py","tests/gateway/test_whatsapp_bridge_dir_resolution.py"],"fix_hunks":[{"file":"gateway/platforms/whatsapp_common.py","hunks":["@@ -365,3 +365,56 @@ def _header_to_bold(m: re.Match) -> str:\n             result = result.replace(f\"{_CODE_PH}{i}\\x00\", code)\n \n         return result\n+\n+\n+# ---------------------------------------------------------------------------\n+# Shared bridge directory resolution for CLI and adapter\n+# ---------------------------------------------------------------------------\n+\n+def resolve_whatsapp_bridge_dir() -> Path:\n+    \"\"\"Resolve the WhatsApp bridge directory, mirroring to HERMES_HOME if needed.\n+\n+    When the install tree is read-only (e.g., Docker /opt/hermes), this function\n+    mirrors the bridge source to a writable HERMES_HOME location and returns that\n+    path. This ensures npm install works in Docker environments.\n+\n+    Returns the resolved bridge directory path.\n+    \"\"\"\n+    import shutil\n+    from pathlib import Path as _Path\n+\n+    # Default location in install tree (may be read-only)\n+    from hermes_constants import get_hermes_home\n+    install_bridge = _Path(__file__).resolve().parents[2] / \"scripts\" / \"whatsapp-bridge\"\n+\n+    # Try HERMES_HOME location first\n+    hermes_home = get_hermes_home()\n+    hermes_home_bridge = hermes_home / \"scripts\" / \"whatsapp-bridge\"\n+\n+    # Check if install dir is writable\n+    try:\n+        test_file = install_bridge / \".write_test\"\n+        test_file.touch()\n+        test_file.unlink()\n+        install_writable = True\n+    except (OSError, PermissionError):\n+        install_writable = False\n+\n+    if install_writable:\n+        return install_bridge\n+\n+    # Install dir is read-only, mirror to HERMES_HOME if needed\n+    if hermes_home_bridge.exists():\n+        return hermes_home_bridge\n+\n+    # Mirror the bridge source to HERMES_HOME\n+    try:\n+        hermes_home_bridge.parent.mkdir(parents=True, exist_ok=True)\n+        shutil.copytree(\n+            install_bridge,\n+            hermes_home_bridge,\n+            dirs_exist_ok=False,\n+        )\n+        return hermes_home_bridge\n+    except Exception:\n+        return install_bridge"]},{"file":"hermes_cli/main.py","hunks":["@@ -2466,8 +2466,8 @@ def cmd_whatsapp(args):\n             print(\"  ⚠ No allowlist — the agent will respond to ALL incoming messages\")\n \n     # ── Step 4: Install bridge dependencies ──────────────────────────────\n-    project_root = Path(__file__).resolve().parents[1]\n-    bridge_dir = project_root / \"scripts\" / \"whatsapp-bridge\"\n+    from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n+    bridge_dir = resolve_whatsapp_bridge_dir()\n     bridge_script = bridge_dir / \"bridge.js\"\n \n     if not bridge_script.exists():"]},{"file":"plugins/platforms/whatsapp/adapter.py","hunks":["@@ -261,11 +261,15 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):\n     share it. Only transport-specific code lives here.\n     \"\"\"\n \n-    # Default bridge location relative to the hermes-agent install\n-    _DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / \"scripts\" / \"whatsapp-bridge\"\n+    # Default bridge location resolved via shared helper\n+    _DEFAULT_BRIDGE_DIR = None  # resolved in __init__\n \n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.WHATSAPP)\n+        # Use shared helper for bridge directory resolution (handles read-only install tree)\n+        if WhatsAppAdapter._DEFAULT_BRIDGE_DIR is None:\n+            from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n+            WhatsAppAdapter._DEFAULT_BRIDGE_DIR = resolve_whatsapp_bridge_dir()\n         self._bridge_process: Optional[subprocess.Popen] = None\n         self._bridge_port: int = config.extra.get(\"bridge_port\", 3000)\n         self._bridge_script: Optional[str] = config.extra.get("]},{"file":"scripts/release.py","hunks":["@@ -477,6 +477,7 @@\n     \"[redacted:email]\": \"Krionex\",\n     \"rxdxxxx@users.noreply.github.com\": \"rxdxxxx\",\n     \"[redacted:email]\": \"MaHaoHao-ch\",\n+    \"[redacted:email]\": \"xydigit-zt\",\n     \"29756950+revaraver@users.noreply.github.com\": \"revaraver\",\n     \"[redacted:email]\": \"TheEpTic\",\n     \"74554762+wmagev@users.noreply.github.com\": \"wmagev\","]},{"file":"tests/gateway/test_whatsapp_bridge_dir_resolution.py","hunks":["@@ -0,0 +1,120 @@\n+\"\"\"Tests for resolve_whatsapp_bridge_dir() — read-only install tree handling.\n+\n+Regression coverage for #49561: in the Docker image the install tree\n+(/opt/hermes/scripts/whatsapp-bridge) is read-only, so `npm install` fails\n+with EACCES. The resolver must detect the read-only install dir and mirror the\n+bridge source into a writable HERMES_HOME location instead.\n+\"\"\"\n+import importlib\n+from pathlib import Path\n+\n+import pytest\n+\n+from gateway.platforms import whatsapp_common\n+\n+\n+def _seed_install_tree(install_bridge: Path) -> None:\n+    \"\"\"Create a minimal fake bridge source tree.\"\"\"\n+    install_bridge.mkdir(parents=True, exist_ok=True)\n+    (install_bridge / \"bridge.js\").write_text(\"// bridge\\n\")\n+    (install_bridge / \"package.json\").write_text('{\"name\": \"whatsapp-bridge\"}\\n')\n+\n+\n+def test_writable_install_returns_install_dir(tmp_path, monkeypatch):\n+    \"\"\"When the install tree is writable, the resolver returns it unchanged.\"\"\"\n+    install_root = tmp_path / \"install\"\n+    install_bridge = install_root / \"scripts\" / \"whatsapp-bridge\"\n+    _seed_install_tree(install_bridge)\n+\n+    hermes_home = tmp_path / \"hermes_home\"\n+    hermes_home.mkdir()\n+\n+    # Point the resolver's two anchors at our temp dirs.\n+    monkeypatch.setattr(\n+        whatsapp_common, \"__file__\",\n+        str(install_root / \"gateway\" / \"platforms\" / \"whatsapp_common.py\"),\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_constants.get_hermes_home\", lambda: hermes_home\n+    )\n+\n+    resolved = whatsapp_common.resolve_whatsapp_bridge_dir()\n+    assert resolved == install_bridge\n+    # Nothing mirrored into HERMES_HOME.\n+    assert not (hermes_home / \"scripts\" / \"whatsapp-bridge\").exists()\n+\n+\n+def test_readonly_install_mirrors_to_hermes_home(tmp_path, monkeypatch):\n+    \"\"\"A read-only install tree is mirrored into a writable HERMES_HOME.\"\"\"\n+    install_root = tmp_path / \"install\"\n+    install_bridge = install_root / \"scripts\" / \"whatsapp-bridge\"\n+    _seed_install_tree(install_bridge)\n+\n+    hermes_home = tmp_path / \"hermes_home\"\n+    hermes_home.mkdir()\n+\n+    monkeypatch.setattr(\n+        whatsapp_common, \"__file__\",\n+        str(install_root / \"gateway\" / \"platforms\" / \"whatsapp_common.py\"),\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_constants.get_hermes_home\", lambda: hermes_home\n+    )\n+\n+    # Simulate a read-only install tree. chmod(0o555) is unreliable under\n+    # root (CI/Docker bypass permission bits), so force the write probe to\n+    # fail by raising on the .write_test touch for the install dir only.\n+    _real_touch = Path.touch\n+\n+    def _fake_touch(self, *a, **kw):\n+        if self.name == \".write_test\" and install_bridge in self.parents:\n+            raise PermissionError(\"read-only install tree\")\n+        return _real_touch(self, *a, **kw)\n+\n+    monkeypatch.setattr(Path, \"touch\", _fake_touch)\n+\n+    resolved = whatsapp_common.resolve_whatsapp_bridge_dir()\n+\n+    expected = hermes_home / \"scripts\" / \"whatsapp-bridge\"\n+    assert resolved == expected\n+    # Source was mirrored, not symlinked.\n+    assert (expected / \"bridge.js\").read_text() == \"// bridge\\n\"\n+    assert (expected / \"package.json\").exists()\n+\n+\n+def test_readonly_install_reuses_existing_mirror(tmp_path, monkeypatch):\n+    \"\"\"If the HERMES_HOME mirror already exists, return it without re-copying.\"\"\"\n+    install_root = tmp_path / \"install\"\n+    install_bridge = install_root / \"scripts\" / \"whatsapp-bridge\"\n+    _seed_install_tree(install_bridge)\n+\n+    hermes_home = tmp_path / \"hermes_home\"\n+    mirror = hermes_home / \"scripts\" / \"whatsapp-bridge\"\n+    mirror.mkdir(parents=True)\n+    # A sentinel file proves the resolver returned the EXISTING mirror\n+    # rather than wiping/recopying it.\n+    (mirror / \"node_modules\").mkdir()\n+    (mirror / \"node_modules\" / \"sentinel\").write_text(\"keep me\\n\")\n+\n+    monkeypatch.setattr(\n+        whatsapp_common, \"__file__\",\n+        str(install_root / \"gateway\" / \"platforms\" / \"whatsapp_common.py\"),\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_constants.get_hermes_home\", lambda: hermes_home\n+    )\n+\n+    _real_touch = Path.touch\n+\n+    def _fake_touch(self, *a, **kw):\n+        if self.name == \".write_test\" and install_bridge in self.parents:\n+            raise PermissionError(\"read-only install tree\")\n+        return _real_touch(self, *a, **kw)\n+\n+    monkeypatch.setattr(Path, \"touch\", _fake_touch)\n+\n+    resolved = whatsapp_common.resolve_whatsapp_bridge_dir()\n+\n+    assert resolved == mirror\n+    # Existing node_modules left intact (no destructive re-copy).\n+    assert (mirror / \"node_modules\" / \"sentinel\").read_text() == \"keep me\\n\"\n"]}]},{"kind":"pr","number":62980,"title":"fix(photon): install sidecar deps under hermes home","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b62fc24dfa","number":0},{"kind":"pr","value":"#73864","number":73864},{"kind":"pr","value":"#69910","number":69910}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":63906,"title":"fix(install): add --unsafe-perm to npm install for Podman/Docker rootless compat","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":73864,"title":"fix(photon): immutable install trees for the iMessage sidecar (NS-606) [salvage #69910]","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["Dockerfile","plugins/platforms/photon/adapter.py","plugins/platforms/photon/cli.py","plugins/platforms/photon/sidecar_paths.py","tests/plugins/platforms/photon/test_check_requirements_risks.py","tests/plugins/platforms/photon/test_npm_error_log_regression.py","tests/plugins/platforms/photon/test_sidecar_lifecycle.py","tests/plugins/platforms/photon/test_sidecar_paths.py","tests/tools/test_dockerfile_immutable_install.py"]},{"kind":"pr","number":73893,"title":"fix(photon): support immutable install trees for the iMessage sidecar (salvage #69910)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b62fc24dfa","number":0},{"kind":"pr","value":"#73864","number":73864},{"kind":"pr","value":"#69910","number":69910}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}