# NousResearch/hermes-agent · Bridge install failures in read-only/container environments

## Open now

- 1 issue, 0 PRs open on GitHub.
- #62975: \[Bug\]: Node sidecar dep (NPM) error on Podman install

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.

## TL;DR

- 10 member(s): 1 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 6 PR(s) closed without merging.
- A fix was merged to main in PR #73864. Verify it addresses your case.
- **Recommended:** read PR #73864 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-49839`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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.21`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #62975: \[Bug\]: Node sidecar dep (NPM) error on Podman install

## Closed / duplicate issues

- #49561: \[Bug\]: Regression - WhatsApp bridge cannot be installed

## Prior & competing PRs

- **#49839: fix(whatsapp): resolve bridge dir with HERMES_HOME mirror in read-only Docker (fix #49561)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `gateway/platforms/whatsapp_common.py`

    ```diff
    @@ -365,3 +365,56 @@ def _header_to_bold(m: re.Match) -> str:
                 result = result.replace(f"{_CODE_PH}{i}\x00", code)
     
             return result
    +
    +
    +# ---------------------------------------------------------------------------
    +# Shared bridge directory resolution for CLI and adapter
    +# ---------------------------------------------------------------------------
    +
    +def resolve_whatsapp_bridge_dir() -> Path:
    +    """Resolve the WhatsApp bridge directory, mirroring to HERMES_HOME if needed.
    +
    +    When the install tree is read-only (e.g., Docker /opt/hermes), this function
    +    mirrors the bridge source to a writable HERMES_HOME location and returns that
    +    path. This ensures npm install works in Docker environments.
    +
    +    Returns the resolved bridge directory path.
    +    """
    +    import shutil
    +    from pathlib import Path as _Path
    +
    +    # Default location in install tree (may be read-only)
    +    from hermes_constants import get_hermes_home
    +    install_bridge = _Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
    +
    +    # Try HERMES_HOME location first
    +    hermes_home = get_hermes_home()
    +    hermes_home_bridge = hermes_home / "scripts" / "whatsapp-bridge"
    +
    +    # Check if install dir is writable
    +    try:
    +        test_file = install_bridge / ".write_test"
    +        test_file.touch()
    +        test_file.unlink()
    +        install_writable = True
    +    except (OSError, PermissionError):
    +        install_writable = False
    +
    +    if install_writable:
    +        return install_bridge
    +
    +    # Install dir is read-only, mirror to HERMES_HOME if needed
    +    if hermes_home_bridge.exists():
    +        return hermes_home_bridge
    +
    +    # Mirror the bridge source to HERMES_HOME
    +    try:
    +        hermes_home_bridge.parent.mkdir(parents=True, exist_ok=True)
    +        shutil.copytree(
    +            install_bridge,
    +            hermes_home_bridge,
    +            dirs_exist_ok=False,
    +        )
    +        return hermes_home_bridge
    +    except Exception:
    +        return install_bridge
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -2466,8 +2466,8 @@ def cmd_whatsapp(args):
                 print("  ⚠ No allowlist — the agent will respond to ALL incoming messages")
     
         # ── Step 4: Install bridge dependencies ──────────────────────────────
    -    project_root = Path(__file__).resolve().parents[1]
    -    bridge_dir = project_root / "scripts" / "whatsapp-bridge"
    +    from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir
    +    bridge_dir = resolve_whatsapp_bridge_dir()
         bridge_script = bridge_dir / "bridge.js"
     
         if not bridge_script.exists():
    ```
  - `plugins/platforms/whatsapp/adapter.py`

    ```diff
    @@ -261,11 +261,15 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
         share it. Only transport-specific code lives here.
         """
     
    -    # Default bridge location relative to the hermes-agent install
    -    _DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
    +    # Default bridge location resolved via shared helper
    +    _DEFAULT_BRIDGE_DIR = None  # resolved in __init__
     
         def __init__(self, config: PlatformConfig):
             super().__init__(config, Platform.WHATSAPP)
    +        # Use shared helper for bridge directory resolution (handles read-only install tree)
    +        if WhatsAppAdapter._DEFAULT_BRIDGE_DIR is None:
    +            from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir
    +            WhatsAppAdapter._DEFAULT_BRIDGE_DIR = resolve_whatsapp_bridge_dir()
             self._bridge_process: Optional[subprocess.Popen] = None
             self._bridge_port: int = config.extra.get("bridge_port", 3000)
             self._bridge_script: Optional[str] = config.extra.get(
    ```
  - `scripts/release.py`

    ```diff
    @@ -477,6 +477,7 @@
         "[redacted:email]": "Krionex",
         "rxdxxxx@users.noreply.github.com": "rxdxxxx",
         "[redacted:email]": "MaHaoHao-ch",
    +    "[redacted:email]": "xydigit-zt",
         "29756950+revaraver@users.noreply.github.com": "revaraver",
         "[redacted:email]": "TheEpTic",
         "74554762+wmagev@users.noreply.github.com": "wmagev",
    ```
  - `tests/gateway/test_whatsapp_bridge_dir_resolution.py`

    ```diff
    @@ -0,0 +1,120 @@
    +"""Tests for resolve_whatsapp_bridge_dir() — read-only install tree handling.
    +
    +Regression coverage for #49561: in the Docker image the install tree
    +(/opt/hermes/scripts/whatsapp-bridge) is read-only, so `npm install` fails
    +with EACCES. The resolver must detect the read-only install dir and mirror the
    +bridge source into a writable HERMES_HOME location instead.
    +"""
    +import importlib
    +from pathlib import Path
    +
    +import pytest
    +
    +from gateway.platforms import whatsapp_common
    +
    +
    +def _seed_install_tree(install_bridge: Path) -> None:
    +    """Create a minimal fake bridge source tree."""
    +    install_bridge.mkdir(parents=True, exist_ok=True)
    +    (install_bridge / "bridge.js").write_text("// bridge\n")
    +    (install_bridge / "package.json").write_text('{"name": "whatsapp-bridge"}\n')
    +
    +
    +def test_writable_install_returns_install_dir(tmp_path, monkeypatch):
    +    """When the install tree is writable, the resolver returns it unchanged."""
    +    install_root = tmp_path / "install"
    +    install_bridge = install_root / "scripts" / "whatsapp-bridge"
    +    _seed_install_tree(install_bridge)
    +
    +    hermes_home = tmp_path / "hermes_home"
    +    hermes_home.mkdir()
    +
    +    # Point the resolver's two anchors at our temp dirs.
    +    monkeypatch.setattr(
    +        whatsapp_common, "__file__",
    +        str(install_root / "gateway" / "platforms" / "whatsapp_common.py"),
    +    )
    +    monkeypatch.setattr(
    +        "hermes_constants.get_hermes_home", lambda: hermes_home
    +    )
    +
    +    resolved = whatsapp_common.resolve_whatsapp_bridge_dir()
    +    assert resolved == install_bridge
    +    # Nothing mirrored into HERMES_HOME.
    +    assert not (hermes_home / "scripts" / "whatsapp-bridge").exists()
    +
    +
    +def test_readonly_install_mirrors_to_hermes_home(tmp_path, monkeypatch):
    +    """A read-only install tree is mirrored into a writable HERMES_HOME."""
    +    install_root = tmp_path / "install"
    +    install_bridge = install_root / "scripts" / "whatsapp-bridge"
    +    _seed_install_tree(install_bridge)
    +
    +    hermes_home = tmp_path / "hermes_home"
    +    hermes_home.mkdir()
    +
    +    monkeypatch.setattr(
    +        whatsapp_common, "__file__",
    +        str(install_root / "gateway" / "platforms" / "whatsapp_common.py"),
    +    )
    +    monkeypatch.setattr(
    +        "hermes_constants.get_hermes_home", lambda: hermes_home
    +    )
    +
    +    # Simulate a read-only install tree. chmod(0o555) is unreliable under
    +    # root (CI/Docker bypass permission bits), so force the write probe to
    +    # fail by raising on the .write_test touch for the install dir only.
    +    _real_touch = Path.touch
    +
    +    def _fake_touch(self, *a, **kw):
    +        if self.name == ".write_test" and install_bridge in self.parents:
    +            raise PermissionError("read-only install tree")
    +        return _real_touch(self, *a, **kw)
    +
    +    monkeypatch.setattr(Path, "touch", _fake_touch)
    +
    +    resolved = whatsapp_common.resolve_whatsapp_bridge_dir()
    +
    +    expected = hermes_home / "scripts" / "whatsapp-bridge"
    +    assert resolved == expected
    +    # Source was mirrored, not symlinked.
    +    assert (expected / "bridge.js").read_text() == "// bridge\n"
    +    assert (expected / "package.json").exists()
    +
    +
    +def test_readonly_install_reuses_existing_mirror(tmp_path, monkeypatch):
    +    """If the HERMES_HOME mirror already exists, return it without re-copying."""
    +    install_root = tmp_path / "install"
    +    install_bridge = install_root / "scripts" / "whatsapp-bridge"
    +    _seed_install_tree(install_bridge)
    +
    +    hermes_home = tmp_path / "hermes_home"
    +    mirror = hermes_home / "scripts" / "whatsapp-bridge"
    +    mirror.mkdir(parents=True)
    +    # A sentinel file proves the resolver returned the EXISTING mirror
    +    # rather than wiping/recopying it.
    +    (mirror / "node_modules").mkdir()
    +    (mirror / "node_modules" / "sentinel").write_text("keep me\n")
    +
    +    monkeypatch.setattr(
    +        whatsapp_common, "__file__",
    +        str(install_root / "gateway" / "platforms" / "whatsapp_common.py"),
    +    )
    +    monkeypatch.setattr(
    +        "hermes_constants.get_hermes_home", lambda: hermes_home
    +    )
    +
    +    _real_touch = Path.touch
    +
    +    def _fake_touch(self, *a, **kw):
    +        if self.name == ".write_test" and install_bridge in self.parents:
    +            raise PermissionError("read-only install tree")
    +        return _real_touch(self, *a, **kw)
    +
    +    monkeypatch.setattr(Path, "touch", _fake_touch)
    +
    +    resolved = whatsapp_common.resolve_whatsapp_bridge_dir()
    +
    +    assert resolved == mirror
    +    # Existing node_modules left intact (no destructive re-copy).
    +    assert (mirror / "node_modules" / "sentinel").read_text() == "keep me\n"
    ```
- **#73864: fix(photon): immutable install trees for the iMessage sidecar (NS-606) \[salvage #69910\]**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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`
- **#49584: fix(whatsapp): use writable HERMES_HOME for bridge in Docker (fix #49561)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#49557`, `#49599`, `#49561`
- **#49599: fix(whatsapp): install bridge into a writable dir on read-only installs (fix #49561)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#49561`, `#49584`, `#49569`
- **#49654: fix(whatsapp): resolve bridge dir with HERMES_HOME mirror (fix #49561)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `491579fa05`, `#49839`
- **#62980: fix(photon): install sidecar deps under hermes home**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `b62fc24dfa`, `#73864`, `#69910`
- **#63906: fix(install): add --unsafe-perm to npm install for Podman/Docker rootless compat**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#73893: fix(photon): support immutable install trees for the iMessage sidecar (salvage #69910)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `b62fc24dfa`, `#73864`, `#69910`

## Fix status

- Merged to main in PR #73864 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-29`.
- Subsystem: `environments`
