diff --git a/scripts/release.py b/scripts/release.py index 9958774bd80..dd0736bd96c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -478,6 +478,7 @@ AUTHOR_MAP = { "krionex1@gmail.com": "Krionex", "rxdxxxx@users.noreply.github.com": "rxdxxxx", "ma.haohao2@xydigit.com": "MaHaoHao-ch", + "zheng.tao@xydigit.com": "xydigit-zt", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", diff --git a/tests/gateway/test_whatsapp_bridge_dir_resolution.py b/tests/gateway/test_whatsapp_bridge_dir_resolution.py new file mode 100644 index 00000000000..fc65f323e38 --- /dev/null +++ b/tests/gateway/test_whatsapp_bridge_dir_resolution.py @@ -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"