Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,13 @@ hermes-agent/
│ ├── run.py # GatewayRunner — platform lifecycle, message routing, cron
│ ├── config.py # Platform configuration resolution
│ ├── session.py # Session store, context prompts, reset policies
│ ├── whatsapp_bridge/ # Embedded Node.js WhatsApp bridge (shipped as package-data)
│ └── platforms/ # Platform adapters
│ ├── telegram.py, discord_adapter.py, slack.py, whatsapp.py
├── scripts/ # Installer and bridge scripts
├── scripts/ # Installer and dev scripts
│ ├── install.sh # Linux/macOS installer
│ ├── install.ps1 # Windows PowerShell installer
│ └── whatsapp-bridge/ # Node.js WhatsApp bridge (Baileys)
│ └── install.ps1 # Windows PowerShell installer
├── skills/ # Bundled skills (copied to ~/.hermes/skills/ on install)
├── optional-skills/ # Official optional skills (discoverable via hub, not activated by default)
Expand Down
126 changes: 119 additions & 7 deletions gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@
import os
import platform
import re
import shutil
import subprocess

_IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
from typing import Dict, Optional, Any

from hermes_constants import get_hermes_dir
from hermes_constants import get_hermes_dir, get_hermes_home

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -115,7 +116,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None:
def check_whatsapp_requirements() -> bool:
"""
Check if WhatsApp dependencies are available.

WhatsApp requires a Node.js bridge for most implementations.
"""
# Check for Node.js
Expand All @@ -131,6 +132,84 @@ def check_whatsapp_requirements() -> bool:
return False


# ── Bridge file lifecycle (#15336, #15460 follow-up) ───────────────────────

# Files that must be present in the runtime bridge directory before the
# Node process can start. ``__init__.py`` (the Python package marker
# from the template location) is intentionally excluded — it's a
# Python-side artefact, not part of the Node app.
_BRIDGE_TEMPLATE_FILES = (
"bridge.js",
"allowlist.js",
"allowlist.test.mjs",
"package.json",
"package-lock.json",
)


def _resolve_runtime_bridge_dir() -> Path:
"""Return the writable runtime location for the WhatsApp bridge.

The bridge files ship as package-data inside the ``gateway`` package
(``site-packages/gateway/whatsapp_bridge/``). That location is
read-only on Nix store and many system pip installs, so running
``npm install`` directly there fails with EACCES / EROFS.

We materialise a writable copy under ``HERMES_HOME`` instead — by
convention at ``~/.hermes/whatsapp-bridge/`` — and let npm manage
``node_modules`` there. Honouring ``get_hermes_home()`` means
profile-isolated installs and Docker volumes work correctly without
extra wiring.
"""
return get_hermes_home() / "whatsapp-bridge"


def _ensure_runtime_bridge_files(template_dir: Path, runtime_dir: Path) -> None:
"""Copy template bridge files to the writable runtime location.

Copies are mtime-aware: a file is only re-copied when the template
is newer than the runtime copy (or the runtime copy is missing
entirely). That way Hermes upgrades pick up bridge updates on the
next start-up without burning IO every time.

``node_modules/`` is never copied — that's npm's job and copying it
would defeat the lockfile reproducibility ``npm ci`` relies on.

File modes are normalised to ``0o644`` on copy because pip /
setuptools may ship package-data at ``0o444`` (read-only on the Nix
store), and ``npm install`` needs to overwrite ``package-lock.json``
when resolving dependencies.

No-ops cleanly when the template is missing (development checkouts
that never ran ``pip install -e .``) — callers are expected to
handle the missing-bridge case themselves.
"""
if not template_dir.is_dir():
return
runtime_dir.mkdir(parents=True, exist_ok=True)
for filename in _BRIDGE_TEMPLATE_FILES:
src = template_dir / filename
if not src.is_file():
continue
dst = runtime_dir / filename
if dst.exists():
try:
if dst.stat().st_mtime >= src.stat().st_mtime:
continue
except OSError:
# Fall through and re-copy — better to overwrite a
# questionable runtime file than to skip a stale one.
pass
try:
shutil.copy2(src, dst)
os.chmod(dst, 0o644)
except (OSError, PermissionError) as exc:
logger.warning(
"Failed to copy WhatsApp bridge file %s -> %s: %s",
src, dst, exc,
)


class WhatsAppAdapter(BasePlatformAdapter):
"""
WhatsApp adapter.
Expand Down Expand Up @@ -159,16 +238,39 @@ class WhatsAppAdapter(BasePlatformAdapter):
# WhatsApp allows ~65K but long messages are unreadable on mobile.
MAX_MESSAGE_LENGTH = 4096

# Default bridge location relative to the hermes-agent install
_DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
# Read-only template location: the bridge files ship as package-data
# of the ``gateway`` package (``gateway/whatsapp_bridge/``). Resolving
# via ``__file__.parents[1]`` (gateway/) keeps the path correct under
# both source-tree runs and installed wheels (#15336). This is the
# SOURCE of truth for the bridge JS — but it lives in site-packages,
# which is read-only on Nix store / system pip installs. We copy
# into a writable runtime dir before running ``npm install``; see
# ``_resolve_runtime_bridge_dir`` and ``_ensure_runtime_bridge_files``.
_DEFAULT_BRIDGE_TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "whatsapp_bridge"

# Backward-compat alias. External callers (and a handful of tests)
# reference ``_DEFAULT_BRIDGE_DIR`` to discover where the bridge
# lives at runtime. Now that we materialise into HERMES_HOME, we
# redirect to the runtime location — but only when one already
# exists, so attribute access at *class* definition time (no
# HERMES_HOME yet) doesn't trigger a copy. The class attribute
# below is the template location; instances resolve the runtime
# location lazily in ``__init__``.
_DEFAULT_BRIDGE_DIR = _DEFAULT_BRIDGE_TEMPLATE_DIR

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WHATSAPP)
self._bridge_process: Optional[subprocess.Popen] = None
self._bridge_port: int = config.extra.get("bridge_port", 3000)
# The default bridge script is the writable runtime copy under
# HERMES_HOME, NOT the read-only template in site-packages
# (#15460 follow-up). Operators can still override via
# ``bridge_script`` in the platform config if they have a
# custom Node.js bridge build.
self._runtime_bridge_dir: Path = _resolve_runtime_bridge_dir()
self._bridge_script: Optional[str] = config.extra.get(
"bridge_script",
str(self._DEFAULT_BRIDGE_DIR / "bridge.js"),
str(self._runtime_bridge_dir / "bridge.js"),
)
self._session_path: Path = Path(config.extra.get(
"session_path",
Expand Down Expand Up @@ -356,12 +458,22 @@ async def connect(self) -> bool:
if not check_whatsapp_requirements():
logger.warning("[%s] Node.js not found. WhatsApp requires Node.js.", self.name)
return False


# Materialise the bridge files into the writable runtime dir on
# first start (or after a Hermes upgrade — copy is mtime-aware).
# Skipped silently if the operator overrode ``bridge_script`` to
# point at their own custom build outside HERMES_HOME.
bridge_path = Path(self._bridge_script)
if bridge_path.parent == self._runtime_bridge_dir:
_ensure_runtime_bridge_files(
self._DEFAULT_BRIDGE_TEMPLATE_DIR,
self._runtime_bridge_dir,
)

if not bridge_path.exists():
logger.warning("[%s] Bridge script not found: %s", self.name, bridge_path)
return False

logger.info("[%s] Bridge found at %s", self.name, bridge_path)

# Acquire scoped lock to prevent duplicate sessions
Expand Down
22 changes: 22 additions & 0 deletions gateway/whatsapp_bridge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Embedded WhatsApp bridge (Node.js).

This package vendors the small Node.js daemon that talks to WhatsApp via
Baileys (``@whiskeysockets/baileys``). It lives inside the ``gateway``
package so setuptools picks it up as package-data and ships it to
``site-packages/gateway/whatsapp_bridge/`` — which fixes #15336 (NixOS /
pip-installed builds previously had no copy on disk because
``scripts/whatsapp-bridge/`` was outside any Python package).

This site-packages location is a **read-only template**. The WhatsApp
adapter copies these files to a writable directory under
``HERMES_HOME`` (``~/.hermes/whatsapp-bridge/``) on first use, then runs
``npm install`` there. That two-step is mandatory because the Nix
store and many system pip installs are read-only — running
``npm install`` directly inside ``site-packages`` would fail.

The directory is intentionally a regular package (with this ``__init__``)
rather than a namespace package so package-data globs resolve cleanly
on every modern setuptools. Nothing in here is meant to be imported
from Python — the consumer (``gateway.platforms.whatsapp``) launches
``bridge.js`` via ``subprocess`` from the runtime copy.
"""
File renamed without changes.
20 changes: 18 additions & 2 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -951,11 +951,27 @@ def run_doctor(args):
else:
check_warn("Node.js not found", "(optional, needed for browser tools)")

# npm audit for all Node.js packages
# npm audit for all Node.js packages.
# The WhatsApp bridge is audited at its writable *runtime* location
# (``~/.hermes/whatsapp-bridge/``) because that's where ``npm
# install`` actually runs — the site-packages template location
# ships the JS sources but never has ``node_modules`` to audit
# (#15336, #15460 follow-up). Fall back to source-tree lookups
# only if the import fails (dev checkout without an installed pkg).
if _safe_which("npm"):
try:
from gateway.platforms.whatsapp import _resolve_runtime_bridge_dir
_whatsapp_bridge_dir = _resolve_runtime_bridge_dir()
except (ImportError, AttributeError):
# Dev checkout without an installed gateway package: fall
# back to the legacy in-tree path so the dev loop still
# sees the bridge. Narrow the exception set so a genuine
# bug inside the gateway module surfaces loudly rather than
# being swallowed as "no bridge found".
_whatsapp_bridge_dir = PROJECT_ROOT / "gateway" / "whatsapp_bridge"
npm_dirs = [
(PROJECT_ROOT, "Browser tools (agent-browser)"),
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
(_whatsapp_bridge_dir, "WhatsApp bridge"),
]
for npm_dir, label in npm_dirs:
if not (npm_dir / "node_modules").exists():
Expand Down
16 changes: 14 additions & 2 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1447,8 +1447,20 @@ 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"
# Bridge files were moved from ``scripts/whatsapp-bridge/`` (outside
# any package) to the ``gateway`` package (#15336) so setuptools /
# pip / Nix would actually ship them. But the package-data location
# (``site-packages/gateway/whatsapp_bridge/``) is read-only on Nix
# and on system pip installs — so ``npm install`` would fail there.
# Materialise a writable copy under HERMES_HOME and run npm there.
from gateway.platforms.whatsapp import (
WhatsAppAdapter,
_resolve_runtime_bridge_dir,
_ensure_runtime_bridge_files,
)
template_dir = WhatsAppAdapter._DEFAULT_BRIDGE_TEMPLATE_DIR
bridge_dir = _resolve_runtime_bridge_dir()
_ensure_runtime_bridge_files(template_dir, bridge_dir)
bridge_script = bridge_dir / "bridge.js"

if not bridge_script.exists():
Expand Down
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector

[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*"]
# Embed the WhatsApp bridge (Node.js) so pip / Nix / Docker installs
# ship the bridge files at ``site-packages/gateway/whatsapp_bridge/``
# rather than leaving them outside the wheel (#15336). The glob covers
# JS sources, package.json, and the package-lock (the npm install step
# in ``hermes setup`` re-creates ``node_modules`` at runtime, so we
# deliberately do NOT bundle the lockfile-resolved tree).
"gateway.whatsapp_bridge" = ["*.js", "*.mjs", "package.json", "package-lock.json"]

[tool.setuptools.packages.find]
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
Expand Down
Loading
Loading