fix(gateway): inject PATH + VIRTUAL_ENV into launchd plist for macOS service (#3585)
Salvage of PR #2173 (hanai) and PR #3432 (timknip). Injects PATH, VIRTUAL_ENV, and HERMES_HOME into the macOS launchd plist so gateway subprocesses find user-installed tools (node, ffmpeg, etc.). Matches systemd unit parity with venv/bin, node_modules/.bin, and resolved node dir in PATH. Includes 7 new tests and docs updates across 4 pages. Co-Authored-By: Han <ihanai1991@gmail.com> Co-Authored-By: timknip <timknip@users.noreply.github.com>
This commit is contained in:
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
@@ -835,6 +855,10 @@ def generate_launchd_plist() -> str:
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>{sane_path}</string>
|
||||
<key>VIRTUAL_ENV</key>
|
||||
<string>{venv_dir}</string>
|
||||
<key>HERMES_HOME</key>
|
||||
<string>{hermes_home}</string>
|
||||
</dict>
|
||||
|
||||
@@ -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 "<key>EnvironmentVariables</key>" in plist
|
||||
assert "<key>PATH</key>" in plist
|
||||
assert "<key>VIRTUAL_ENV</key>" in plist
|
||||
assert "<key>HERMES_HOME</key>" 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 "<key>PATH</key>" in line.strip():
|
||||
path_value = lines[i + 1].strip()
|
||||
path_value = path_value.replace("<string>", "").replace("</string>", "")
|
||||
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 "<key>PATH</key>" in line.strip():
|
||||
path_value = lines[i + 1].strip()
|
||||
path_value = path_value.replace("<string>", "").replace("</string>", "")
|
||||
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 "<key>PATH</key>" in line.strip():
|
||||
path_value = lines[i + 1].strip()
|
||||
path_value = path_value.replace("<string>", "").replace("</string>", "")
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<suffix>`.
|
||||
:::
|
||||
|
||||
## Platform-Specific Toolsets
|
||||
|
||||
Each platform has its own toolset:
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user