diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30d171543bb2..e4197cb6467a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index a82417a6015c..e2fe3347c7c8 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -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__) @@ -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 @@ -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. @@ -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", @@ -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 diff --git a/gateway/whatsapp_bridge/__init__.py b/gateway/whatsapp_bridge/__init__.py new file mode 100644 index 000000000000..077e2f762e77 --- /dev/null +++ b/gateway/whatsapp_bridge/__init__.py @@ -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. +""" diff --git a/scripts/whatsapp-bridge/allowlist.js b/gateway/whatsapp_bridge/allowlist.js similarity index 100% rename from scripts/whatsapp-bridge/allowlist.js rename to gateway/whatsapp_bridge/allowlist.js diff --git a/scripts/whatsapp-bridge/allowlist.test.mjs b/gateway/whatsapp_bridge/allowlist.test.mjs similarity index 100% rename from scripts/whatsapp-bridge/allowlist.test.mjs rename to gateway/whatsapp_bridge/allowlist.test.mjs diff --git a/scripts/whatsapp-bridge/bridge.js b/gateway/whatsapp_bridge/bridge.js similarity index 100% rename from scripts/whatsapp-bridge/bridge.js rename to gateway/whatsapp_bridge/bridge.js diff --git a/scripts/whatsapp-bridge/package-lock.json b/gateway/whatsapp_bridge/package-lock.json similarity index 100% rename from scripts/whatsapp-bridge/package-lock.json rename to gateway/whatsapp_bridge/package-lock.json diff --git a/scripts/whatsapp-bridge/package.json b/gateway/whatsapp_bridge/package.json similarity index 100% rename from scripts/whatsapp-bridge/package.json rename to gateway/whatsapp_bridge/package.json diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index f0822bdce8cb..0091965ee7e9 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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(): diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 79ef21eec7b5..97a805cb9b39 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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(): diff --git a/pyproject.toml b/pyproject.toml index a58e172795e6..320ef3c9c15b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.*"] diff --git a/tests/gateway/test_whatsapp_bridge_packaging.py b/tests/gateway/test_whatsapp_bridge_packaging.py new file mode 100644 index 000000000000..44cb27416945 --- /dev/null +++ b/tests/gateway/test_whatsapp_bridge_packaging.py @@ -0,0 +1,292 @@ +"""Regression guard for #15336 — WhatsApp bridge.js must ship in installed wheels. + +Before this fix, ``scripts/whatsapp-bridge/bridge.js`` lived outside +any Python package, so ``pip install`` (and downstream Nix / +Docker / Homebrew installs) simply didn't include it in the wheel. +Users who started Hermes from a packaged install hit: + + ✗ Bridge script not found at /nix/store/.../site-packages/scripts/whatsapp-bridge/bridge.js + +The bridge files were moved into ``gateway/whatsapp_bridge/`` (a real +sub-package of ``gateway``) and registered as setuptools +``package-data`` so they end up at ``site-packages/gateway/ +whatsapp_bridge/`` on every wheel-based install path. + +These tests pin two invariants: + +1. The bridge directory is *findable* from the ``gateway`` package's + ``__file__`` — the resolution path that + ``WhatsAppAdapter._DEFAULT_BRIDGE_DIR`` and the doctor / setup + commands now use. +2. The expected files (``bridge.js``, ``allowlist.js``, ``package.json``) + exist in that directory. If a future refactor removes one of them + without updating callers, this test fails before the next release + ships. +""" +import os +from pathlib import Path + + +def test_bridge_dir_resolves_from_gateway_package(): + """``WhatsAppAdapter._DEFAULT_BRIDGE_DIR`` must compute to a real + directory that exists in the source tree (and therefore — given + the ``[tool.setuptools.package-data]`` entry pinned in + ``pyproject.toml`` — also exists at ``site-packages/gateway/ + whatsapp_bridge/`` after a wheel install).""" + from gateway.platforms.whatsapp import WhatsAppAdapter + + bridge_dir = WhatsAppAdapter._DEFAULT_BRIDGE_DIR + assert isinstance(bridge_dir, Path) + assert bridge_dir.exists(), ( + f"_DEFAULT_BRIDGE_DIR resolved to {bridge_dir} which does not " + f"exist on disk — the bridge files were likely moved without " + f"updating the resolver, or the move regressed (#15336)" + ) + assert bridge_dir.is_dir() + + +def test_bridge_dir_lives_inside_gateway_package(): + """The bridge directory must be ``gateway/whatsapp_bridge/`` so the + ``gateway.whatsapp_bridge`` package-data entry in ``pyproject.toml`` + actually targets it. If a future refactor moves the directory + elsewhere without updating ``pyproject.toml``, the wheel will silently + stop including the bridge files again — same #15336 regression we + just fixed. + """ + import gateway as _gateway_pkg + from gateway.platforms.whatsapp import WhatsAppAdapter + + bridge_dir = WhatsAppAdapter._DEFAULT_BRIDGE_DIR + expected_parent = Path(_gateway_pkg.__file__).resolve().parent + assert bridge_dir.parent == expected_parent, ( + f"bridge dir parent is {bridge_dir.parent}, expected " + f"{expected_parent}. If you moved the bridge, update " + f"pyproject.toml ``[tool.setuptools.package-data]`` to match." + ) + assert bridge_dir.name == "whatsapp_bridge" + + +def test_bridge_dir_contains_required_files(): + """Pin the file list the consumers depend on — ``bridge.js`` + (the entry-point), ``allowlist.js`` (loaded by bridge.js at + runtime), and ``package.json`` (so ``npm install`` works in + the ``hermes setup`` flow).""" + from gateway.platforms.whatsapp import WhatsAppAdapter + + bridge_dir = WhatsAppAdapter._DEFAULT_BRIDGE_DIR + for required in ("bridge.js", "allowlist.js", "package.json"): + target = bridge_dir / required + assert target.exists(), ( + f"required bridge file missing: {target}. Either the file " + f"was deleted (and callers need updating) or the move " + f"regressed (#15336)" + ) + + +def test_pyproject_package_data_covers_bridge_files(): + """``pyproject.toml`` must declare ``gateway.whatsapp_bridge`` as + a package and include the bridge file globs as package-data; + otherwise the wheel-build path leaves the directory empty even + though the source tree has it. + + Parses ``pyproject.toml`` directly so this test catches the + regression even when running outside an installed wheel. + """ + try: + import tomllib # py3.11+ + except ImportError: # pragma: no cover — older Pythons + import tomli as tomllib + + repo_root = Path(__file__).resolve().parents[2] + with open(repo_root / "pyproject.toml", "rb") as fp: + cfg = tomllib.load(fp) + + package_data = ( + cfg.get("tool", {}) + .get("setuptools", {}) + .get("package-data", {}) + ) + bridge_globs = package_data.get("gateway.whatsapp_bridge") + assert bridge_globs, ( + "pyproject.toml is missing the " + "``[tool.setuptools.package-data] \"gateway.whatsapp_bridge\"`` " + "entry — wheel installs will not contain bridge.js (#15336)" + ) + # Pin the patterns we rely on so a future trim doesn't silently + # drop the package.json or the JS sources. + pattern_str = " ".join(bridge_globs) + for needle in ("*.js", "package.json"): + assert needle in pattern_str, ( + f"package-data globs for gateway.whatsapp_bridge are missing " + f"{needle!r}: {bridge_globs!r}" + ) + + +def test_bridge_init_marker_present(): + """The ``__init__.py`` marker is what makes + ``gateway/whatsapp_bridge/`` a regular setuptools package (rather + than a PEP 420 namespace package, which has spottier package-data + support across setuptools versions). Without it, ``find_packages`` + skips the directory and the wheel ships nothing.""" + from gateway.platforms.whatsapp import WhatsAppAdapter + + init = WhatsAppAdapter._DEFAULT_BRIDGE_DIR / "__init__.py" + assert init.exists(), ( + f"missing {init} — without it the directory isn't a real " + f"setuptools package and package-data is skipped on some " + f"setuptools versions (#15336)" + ) + + +# --------------------------------------------------------------------------- +# Runtime-vs-template separation (#15460 follow-up) +# +# The template location lives in site-packages (read-only on Nix / system +# pip installs). ``npm install`` has to run in a writable location, so +# the adapter materialises the JS files into ``HERMES_HOME/whatsapp-bridge/`` +# on first start. These tests pin that separation so a future refactor +# can't silently point npm back at site-packages. +# --------------------------------------------------------------------------- + + +def test_runtime_bridge_dir_lives_under_hermes_home(tmp_path, monkeypatch): + """The writable runtime dir must resolve under ``HERMES_HOME`` — + never site-packages. Honouring ``HERMES_HOME`` means profile- + isolated installs and Docker volumes work without extra wiring.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + from gateway.platforms.whatsapp import _resolve_runtime_bridge_dir + + runtime_dir = _resolve_runtime_bridge_dir() + assert runtime_dir == tmp_path / "hermes" / "whatsapp-bridge" + + +def test_ensure_runtime_bridge_files_copies_template(tmp_path, monkeypatch): + """First-boot flow: runtime dir is empty, template has the JS + sources; the helper must create the runtime dir and copy the + expected files. ``node_modules`` and the Python ``__init__.py`` + marker must be skipped — npm manages the former, the latter is + Python-internal.""" + from gateway.platforms.whatsapp import ( + _BRIDGE_TEMPLATE_FILES, + _ensure_runtime_bridge_files, + ) + + template_dir = tmp_path / "template" + template_dir.mkdir() + for fname in _BRIDGE_TEMPLATE_FILES: + (template_dir / fname).write_text(f"stub-{fname}") + (template_dir / "__init__.py").write_text("# python marker") + (template_dir / "node_modules").mkdir() + (template_dir / "node_modules" / "pretend").write_text("should not copy") + + runtime_dir = tmp_path / "runtime" + _ensure_runtime_bridge_files(template_dir, runtime_dir) + + for fname in _BRIDGE_TEMPLATE_FILES: + assert (runtime_dir / fname).exists(), f"missing {fname} in runtime" + assert not (runtime_dir / "__init__.py").exists(), ( + "Python package marker leaked into runtime — the runtime dir " + "is a pure Node app, not a Python package" + ) + assert not (runtime_dir / "node_modules").exists(), ( + "node_modules was copied from template; npm should manage " + "this at the runtime location" + ) + + +def test_ensure_runtime_bridge_files_chmods_writable(tmp_path): + """Template files may ship at mode 0o444 (read-only on Nix store). + The runtime copy must be writable (0o644) so ``npm install`` can + overwrite ``package-lock.json`` when resolving dependencies.""" + from gateway.platforms.whatsapp import _ensure_runtime_bridge_files + + template_dir = tmp_path / "template" + template_dir.mkdir() + src = template_dir / "package.json" + src.write_text("{}") + os.chmod(src, 0o444) + + runtime_dir = tmp_path / "runtime" + _ensure_runtime_bridge_files(template_dir, runtime_dir) + + dst = runtime_dir / "package.json" + assert dst.exists() + mode = dst.stat().st_mode & 0o777 + assert mode & 0o200, ( + f"runtime bridge file {dst} mode {oct(mode)} is not writable — " + f"npm install will fail with EROFS / EACCES" + ) + + +def test_ensure_runtime_bridge_files_is_mtime_aware(tmp_path, monkeypatch): + """Idempotent re-runs must NOT re-copy when the runtime copy is + already up-to-date — cheap start-up cost. Only stale runtime + files (template newer than runtime) get refreshed.""" + from gateway.platforms.whatsapp import _ensure_runtime_bridge_files + + template_dir = tmp_path / "template" + template_dir.mkdir() + src = template_dir / "bridge.js" + src.write_text("v1") + + runtime_dir = tmp_path / "runtime" + _ensure_runtime_bridge_files(template_dir, runtime_dir) + + # Snapshot mtime of the runtime copy and re-run with the template + # unchanged — runtime file should NOT be re-written. + runtime_file = runtime_dir / "bridge.js" + first_mtime = runtime_file.stat().st_mtime_ns + + _ensure_runtime_bridge_files(template_dir, runtime_dir) + second_mtime = runtime_file.stat().st_mtime_ns + assert second_mtime == first_mtime, ( + "runtime bridge file was re-copied despite template being " + "unchanged — should be a no-op" + ) + + # Now bump the template mtime to simulate a Hermes upgrade + # shipping a newer bridge.js. The runtime copy must refresh. + import time as _time + future_mtime_ns = first_mtime + 1_000_000_000 # 1s in the future + os.utime(src, ns=(future_mtime_ns, future_mtime_ns)) + _ensure_runtime_bridge_files(template_dir, runtime_dir) + third_mtime = runtime_file.stat().st_mtime_ns + assert third_mtime != first_mtime, ( + "template was updated but runtime file stayed stale — " + "Hermes upgrades won't propagate bridge fixes to users" + ) + + +def test_ensure_runtime_bridge_files_handles_missing_template(tmp_path): + """Development checkouts that never ran ``pip install -e .`` may + have no template dir at all. The helper must no-op cleanly in + that case — the caller is responsible for handling the + missing-bridge user-facing error.""" + from gateway.platforms.whatsapp import _ensure_runtime_bridge_files + + template_dir = tmp_path / "does-not-exist" + runtime_dir = tmp_path / "runtime" + + # Must not raise. + _ensure_runtime_bridge_files(template_dir, runtime_dir) + # Must not fabricate an empty runtime dir either — nothing to put + # in it would just confuse callers. + assert not runtime_dir.exists() + + +def test_adapter_default_bridge_script_points_at_runtime(tmp_path, monkeypatch): + """End-to-end: a freshly-constructed ``WhatsAppAdapter`` must have + its ``_bridge_script`` default pointing at the HERMES_HOME runtime + location, never at site-packages. Regression guard against the + bug Copilot caught on #15460 (npm install hitting read-only fs). + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + from gateway.config import PlatformConfig + from gateway.platforms.whatsapp import WhatsAppAdapter + + config = PlatformConfig(enabled=True, extra={}) + adapter = WhatsAppAdapter(config) + assert Path(adapter._bridge_script).parent == tmp_path / "hermes" / "whatsapp-bridge" + + diff --git a/tests/gateway/test_whatsapp_connect.py b/tests/gateway/test_whatsapp_connect.py index 29f7eee3af45..908fc9c03246 100644 --- a/tests/gateway/test_whatsapp_connect.py +++ b/tests/gateway/test_whatsapp_connect.py @@ -47,6 +47,11 @@ def _make_adapter(): adapter.config = MagicMock() adapter._bridge_port = 19876 adapter._bridge_script = "/tmp/test-bridge.js" + # Added in the #15460 follow-up: adapters materialise bridge files + # into a writable runtime dir under HERMES_HOME. Tests that bypass + # ``__init__`` must still set this so ``start()``'s runtime-copy + # check doesn't AttributeError on a partially-built instance. + adapter._runtime_bridge_dir = Path("/tmp/test-wa-runtime") adapter._session_path = Path("/tmp/test-wa-session") adapter._bridge_log_fh = None adapter._bridge_log = None diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py index 129384783538..78af28c8fe5b 100644 --- a/tests/gateway/test_whatsapp_formatting.py +++ b/tests/gateway/test_whatsapp_formatting.py @@ -28,6 +28,11 @@ def _make_adapter(): adapter.config.extra = {} adapter._bridge_port = 3000 adapter._bridge_script = "/tmp/test-bridge.js" + # Added in the #15460 follow-up: adapters materialise bridge files + # into a writable runtime dir under HERMES_HOME. Tests that bypass + # ``__init__`` must set this so ``start()`` doesn't AttributeError. + from pathlib import Path as _Path + adapter._runtime_bridge_dir = _Path("/tmp/test-wa-runtime") adapter._session_path = MagicMock() adapter._bridge_log_fh = None adapter._bridge_log = None diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index 21f8246ace38..2eaa64c6695f 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -265,7 +265,7 @@ The official image is based on `debian:13.4` and includes: - ripgrep, ffmpeg, git, and tini as system utilities - **`docker-cli`** — so agents running inside the container can drive the host's Docker daemon (bind-mount `/var/run/docker.sock` to opt in) for `docker build`, `docker run`, container inspection, etc. - **`openssh-client`** — enables the [SSH terminal backend](/docs/user-guide/configuration#ssh-backend) from inside the container. The SSH backend shells out to the system `ssh` binary; without this, it failed silently in containerized installs. -- The WhatsApp bridge (`scripts/whatsapp-bridge/`) +- The WhatsApp bridge (`gateway/whatsapp_bridge/`) The entrypoint script (`docker/entrypoint.sh`) bootstraps the data volume on first run: - Creates the directory structure (`sessions/`, `memories/`, `skills/`, etc.)