fix(whatsapp): resolve bridge dir with HERMES_HOME mirror in Docker

In Docker the install tree (/opt/hermes) is read-only, so npm install for
the WhatsApp bridge fails with EACCES. Add resolve_whatsapp_bridge_dir() in
whatsapp_common.py: when the install dir is read-only, mirror the bridge
source into a writable HERMES_HOME location and use that. Both the
adapter and the 'hermes whatsapp' CLI resolve through the shared helper so
the install and runtime paths agree.

Fixes #49561
This commit is contained in:
Zheng Tao 2026-06-20 15:11:06 -07:00 committed by Teknium
parent 0a2b712965
commit 491579fa05
3 changed files with 61 additions and 4 deletions

View file

@ -365,3 +365,56 @@ class WhatsAppBehaviorMixin:
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

View file

@ -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():

View file

@ -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(