From 491579fa05eff16767dd25ca6c29e755b1141fd9 Mon Sep 17 00:00:00 2001 From: Zheng Tao Date: Sat, 20 Jun 2026 15:11:06 -0700 Subject: [PATCH] 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 --- gateway/platforms/whatsapp_common.py | 53 +++++++++++++++++++++++++++ hermes_cli/main.py | 4 +- plugins/platforms/whatsapp/adapter.py | 8 +++- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index 6b56be3b8de..c6ed3da6e32 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -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 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 064b69277f6..ef6a176a213 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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(): diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index c692f3536f6..4f5e16d6581 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -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(