diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index a94daf764..92cfefa71 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -812,7 +812,27 @@ def generate_launchd_plist() -> str: log_dir = get_hermes_home() / "logs" log_dir.mkdir(parents=True, exist_ok=True) label = get_launchd_label() - + # Build a sane PATH for the launchd plist. launchd provides only a + # minimal default (/usr/bin:/bin:/usr/sbin:/sbin) which misses Homebrew, + # nvm, cargo, etc. We prepend venv/bin and node_modules/.bin (matching + # the systemd unit), then capture the user's full shell PATH so every + # user-installed tool (node, ffmpeg, …) is reachable. + detected_venv = _detect_venv_dir() + venv_bin = str(detected_venv / "bin") if detected_venv else str(PROJECT_ROOT / "venv" / "bin") + venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") + node_bin = str(PROJECT_ROOT / "node_modules" / ".bin") + # Resolve the directory containing the node binary (e.g. Homebrew, nvm) + # so it's explicitly in PATH even if the user's shell PATH changes later. + priority_dirs = [venv_bin, node_bin] + resolved_node = shutil.which("node") + if resolved_node: + resolved_node_dir = str(Path(resolved_node).resolve().parent) + if resolved_node_dir not in priority_dirs: + priority_dirs.append(resolved_node_dir) + sane_path = ":".join( + dict.fromkeys(priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p]) + ) + return f""" @@ -835,6 +855,10 @@ def generate_launchd_plist() -> str: EnvironmentVariables + PATH + {sane_path} + VIRTUAL_ENV + {venv_dir} HERMES_HOME {hermes_home} diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index b9cdecaa0..ce74f3f65 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -101,6 +101,69 @@ class TestLaunchdPlistReplace: assert replace_idx == run_idx + 1 +class TestLaunchdPlistPath: + def test_plist_contains_environment_variables(self): + plist = gateway_cli.generate_launchd_plist() + assert "EnvironmentVariables" in plist + assert "PATH" in plist + assert "VIRTUAL_ENV" in plist + assert "HERMES_HOME" in plist + + def test_plist_path_includes_venv_bin(self): + plist = gateway_cli.generate_launchd_plist() + detected = gateway_cli._detect_venv_dir() + venv_bin = str(detected / "bin") if detected else str(gateway_cli.PROJECT_ROOT / "venv" / "bin") + assert venv_bin in plist + + def test_plist_path_starts_with_venv_bin(self): + plist = gateway_cli.generate_launchd_plist() + lines = plist.splitlines() + for i, line in enumerate(lines): + if "PATH" in line.strip(): + path_value = lines[i + 1].strip() + path_value = path_value.replace("", "").replace("", "") + detected = gateway_cli._detect_venv_dir() + venv_bin = str(detected / "bin") if detected else str(gateway_cli.PROJECT_ROOT / "venv" / "bin") + assert path_value.startswith(venv_bin + ":") + break + else: + raise AssertionError("PATH key not found in plist") + + def test_plist_path_includes_node_modules_bin(self): + plist = gateway_cli.generate_launchd_plist() + node_bin = str(gateway_cli.PROJECT_ROOT / "node_modules" / ".bin") + lines = plist.splitlines() + for i, line in enumerate(lines): + if "PATH" in line.strip(): + path_value = lines[i + 1].strip() + path_value = path_value.replace("", "").replace("", "") + assert node_bin in path_value.split(":") + break + else: + raise AssertionError("PATH key not found in plist") + + def test_plist_path_includes_current_env_path(self, monkeypatch): + monkeypatch.setenv("PATH", "/custom/bin:/usr/bin:/bin") + plist = gateway_cli.generate_launchd_plist() + assert "/custom/bin" in plist + + def test_plist_path_deduplicates_venv_bin_when_already_in_path(self, monkeypatch): + detected = gateway_cli._detect_venv_dir() + venv_bin = str(detected / "bin") if detected else str(gateway_cli.PROJECT_ROOT / "venv" / "bin") + monkeypatch.setenv("PATH", f"{venv_bin}:/usr/bin:/bin") + plist = gateway_cli.generate_launchd_plist() + lines = plist.splitlines() + for i, line in enumerate(lines): + if "PATH" in line.strip(): + path_value = lines[i + 1].strip() + path_value = path_value.replace("", "").replace("", "") + parts = path_value.split(":") + assert parts.count(venv_bin) == 1 + break + else: + raise AssertionError("PATH key not found in plist") + + # --------------------------------------------------------------------------- # cmd_update — macOS launchd detection # --------------------------------------------------------------------------- diff --git a/website/docs/guides/team-telegram-assistant.md b/website/docs/guides/team-telegram-assistant.md index 88de9c706..04350bfab 100644 --- a/website/docs/guides/team-telegram-assistant.md +++ b/website/docs/guides/team-telegram-assistant.md @@ -168,11 +168,15 @@ journalctl -u hermes-gateway -f ```bash # macOS — manage the service -launchctl start ai.hermes.gateway -launchctl stop ai.hermes.gateway +hermes gateway start +hermes gateway stop tail -f ~/.hermes/logs/gateway.log ``` +:::tip macOS PATH +The launchd plist captures your shell PATH at install time so gateway subprocesses can find tools like Node.js and ffmpeg. If you install new tools later, re-run `hermes gateway install` to update the plist. +::: + ### Verify It's Running ```bash diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index a632bc102..8f0971f3b 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -357,6 +357,23 @@ lsof -i :8080 hermes config show ``` +#### macOS: Node.js / ffmpeg / other tools not found by gateway + +**Cause:** launchd services inherit a minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) that doesn't include Homebrew, nvm, cargo, or other user-installed tool directories. This commonly breaks the WhatsApp bridge (`node not found`) or voice transcription (`ffmpeg not found`). + +**Solution:** The gateway captures your shell PATH when you run `hermes gateway install`. If you installed tools after setting up the gateway, re-run the install to capture the updated PATH: + +```bash +hermes gateway install # Re-snapshots your current PATH +hermes gateway start # Detects the updated plist and reloads +``` + +You can verify the plist has the correct PATH: +```bash +/usr/libexec/PlistBuddy -c "Print :EnvironmentVariables:PATH" \ + ~/Library/LaunchAgents/ai.hermes.gateway.plist +``` + --- ### Performance Issues diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 6069df4f4..cac2a5e1f 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -289,12 +289,27 @@ If you run multiple Hermes installations on the same machine (with different `HE ### macOS (launchd) ```bash -hermes gateway install -launchctl start ai.hermes.gateway -launchctl stop ai.hermes.gateway -tail -f ~/.hermes/logs/gateway.log +hermes gateway install # Install as launchd agent +hermes gateway start # Start the service +hermes gateway stop # Stop the service +hermes gateway status # Check status +tail -f ~/.hermes/logs/gateway.log # View logs ``` +The generated plist lives at `~/Library/LaunchAgents/ai.hermes.gateway.plist`. It includes three environment variables: + +- **PATH** — your full shell PATH at install time, with the venv `bin/` and `node_modules/.bin` prepended. This ensures user-installed tools (Node.js, ffmpeg, etc.) are available to gateway subprocesses like the WhatsApp bridge. +- **VIRTUAL_ENV** — points to the Python virtualenv so tools can resolve packages correctly. +- **HERMES_HOME** — scopes the gateway to your Hermes installation. + +:::tip PATH changes after install +launchd plists are static — if you install new tools (e.g. a new Node.js version via nvm, or ffmpeg via Homebrew) after setting up the gateway, run `hermes gateway install` again to capture the updated PATH. The gateway will detect the stale plist and reload automatically. +::: + +:::info Multiple installations +Like the Linux systemd service, each `HERMES_HOME` directory gets its own launchd label. The default `~/.hermes` uses `ai.hermes.gateway`; other installations use `ai.hermes.gateway-`. +::: + ## Platform-Specific Toolsets Each platform has its own toolset: diff --git a/website/docs/user-guide/messaging/whatsapp.md b/website/docs/user-guide/messaging/whatsapp.md index 57212df15..1c5226813 100644 --- a/website/docs/user-guide/messaging/whatsapp.md +++ b/website/docs/user-guide/messaging/whatsapp.md @@ -173,6 +173,7 @@ whatsapp: | **Logged out unexpectedly** | WhatsApp unlinks devices after long inactivity. Keep the phone on and connected to the network, then re-pair with `hermes whatsapp` if needed. | | **Bridge crashes or reconnect loops** | Restart the gateway, update Hermes, and re-pair if the session was invalidated by a WhatsApp protocol change. | | **Bot stops working after WhatsApp update** | Update Hermes to get the latest bridge version, then re-pair. | +| **macOS: "Node.js not installed" but node works in terminal** | launchd services don't inherit your shell PATH. Run `hermes gateway install` to re-snapshot your current PATH into the plist, then `hermes gateway start`. See the [Gateway Service docs](./index.md#macos-launchd) for details. | | **Messages not being received** | Verify `WHATSAPP_ALLOWED_USERS` includes the sender's number (with country code, no `+` or spaces). | | **Bot replies to strangers with a pairing code** | Set `whatsapp.unauthorized_dm_behavior: ignore` in `~/.hermes/config.yaml` if you want unauthorized DMs to be silently ignored instead. |