fix(wake): resolve claude binary by absolute path for restricted-PATH environments

Background processes (trigger Medic, prax watchdog) inherit a restricted PATH
that omits ~/.local/bin, causing FileNotFoundError when wake_branch() tries to
spawn claude (pip-installed at ~/.local/bin/claude).

Fix: add _find_claude_bin() to locate claude via shutil.which() with fallback
to known install paths; use _CLAUDE_BIN constant in both fresh/resume commands;
explicitly prepend ~/.local/bin to spawn_env PATH in both wake.py and
dispatch_monitor.py. Confirmed by logs: 'Failed to spawn @trigger/@flow:
[Errno 2] No such file or directory: claude'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-04-14 02:56:12 -07:00
co-authored by Claude Sonnet 4.6
parent 03577c0f1f
commit 98191fa97e
3 changed files with 139 additions and 2 deletions
@@ -289,6 +289,11 @@ def main():
venv_bin = str(_repo_root / ".venv" / "bin")
if venv_bin not in spawn_env.get("PATH", ""):
spawn_env["PATH"] = venv_bin + ":" + spawn_env.get("PATH", "")
# Guarantee ~/.local/bin is on PATH for pip-installed tools (e.g. claude)
# Background processes (trigger, prax watchdog) may have restricted PATH.
local_bin = str(Path.home() / ".local" / "bin")
if local_bin not in spawn_env.get("PATH", ""):
spawn_env["PATH"] = local_bin + ":" + spawn_env.get("PATH", "")
for key in list(spawn_env.keys()):
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
spawn_env.pop(key)
@@ -18,6 +18,7 @@ which handles agent lifecycle (cleanup, bounce emails on failure).
import json
import os
import shutil
import sys
import subprocess
import time
@@ -29,6 +30,28 @@ from aipass.ai_mail.apps.handlers.json import json_handler
from aipass.ai_mail.apps.handlers.paths import find_repo_root
def _find_claude_bin() -> str:
"""Locate the claude binary, checking known install locations if not on PATH.
Background processes (trigger Medic, prax watchdog) may have a restricted
PATH without ~/.local/bin. This resolves the absolute path directly.
"""
found = shutil.which("claude")
if found:
return found
for candidate in [
Path.home() / ".local" / "bin" / "claude",
Path("/usr/local/bin/claude"),
Path("/usr/bin/claude"),
]:
if candidate.exists():
return str(candidate)
return "claude" # Last resort — will raise FileNotFoundError if not found
_CLAUDE_BIN = _find_claude_bin()
# Infrastructure paths
_REPO_ROOT = find_repo_root()
_AI_MAIL_DIR = Path(__file__).resolve().parents[3] # ai_mail/
@@ -402,7 +425,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None,
if fresh:
claude_cmd = [
"claude", "-p", prompt,
_CLAUDE_BIN, "-p", prompt,
"--model", resolved_model,
"--max-turns", str(max_turns),
"--permission-mode", "bypassPermissions",
@@ -410,7 +433,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None,
]
else:
claude_cmd = [
"claude", "-c", "-p", prompt,
_CLAUDE_BIN, "-c", "-p", prompt,
"--model", resolved_model,
"--max-turns", str(max_turns),
"--permission-mode", "bypassPermissions",
@@ -443,6 +466,11 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None,
venv_bin = str(_REPO_ROOT / ".venv" / "bin")
if venv_bin not in spawn_env.get("PATH", ""):
spawn_env["PATH"] = venv_bin + ":" + spawn_env.get("PATH", "")
# Guarantee ~/.local/bin is on PATH for pip-installed tools (e.g. claude)
# Background processes (trigger, prax watchdog) may have restricted PATH.
local_bin = str(Path.home() / ".local" / "bin")
if local_bin not in spawn_env.get("PATH", ""):
spawn_env["PATH"] = local_bin + ":" + spawn_env.get("PATH", "")
for key in list(spawn_env.keys()):
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
spawn_env.pop(key)
+104
View File
@@ -21,6 +21,7 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
_check_pid_alive,
_read_session_type,
_clean_zombies,
_find_claude_bin,
resolve_branch,
DispatchStatus,
)
@@ -459,3 +460,106 @@ def test_model_map_values_are_full_ids():
"""All MODEL_MAP values should be full claude model IDs."""
for key, value in MODEL_MAP.items():
assert value.startswith("claude-"), f"{key} -> {value} doesn't start with 'claude-'"
# --- _find_claude_bin tests ------------------------------------------
class TestFindClaudeBin:
"""Tests for _find_claude_bin() — resolves claude binary path."""
def test_uses_shutil_which_when_found(self, monkeypatch):
"""Returns shutil.which result when claude is on PATH."""
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.shutil.which",
lambda _: "/usr/local/bin/claude",
)
result = _find_claude_bin()
assert result == "/usr/local/bin/claude"
def test_falls_back_to_local_bin(self, monkeypatch, tmp_path):
"""Falls back to ~/.local/bin/claude when not on PATH."""
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.shutil.which",
lambda _: None,
)
fake_local_bin = tmp_path / ".local" / "bin"
fake_local_bin.mkdir(parents=True)
fake_claude = fake_local_bin / "claude"
fake_claude.touch()
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.Path.home",
lambda: tmp_path,
)
result = _find_claude_bin()
assert result == str(fake_claude)
def test_falls_back_to_name_when_not_found(self, monkeypatch, tmp_path):
"""Returns bare 'claude' when no known location has the binary."""
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.shutil.which",
lambda _: None,
)
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.Path.home",
lambda: tmp_path,
)
result = _find_claude_bin()
assert result == "claude"
class TestWakeBranchSpawnEnv:
"""Ensure wake_branch() spawn_env includes ~/.local/bin for restricted-PATH envs."""
def test_spawn_env_includes_local_bin(self, tmp_path, monkeypatch):
"""spawn_env PATH includes ~/.local/bin even when not in os.environ PATH."""
# Set up a minimal registry with one branch
branch_path = tmp_path / "src" / "aipass" / "testbranch"
branch_path.mkdir(parents=True)
(branch_path / ".ai_mail.local").mkdir()
registry_file = tmp_path / "AIPASS_REGISTRY.json"
import json
registry_file.write_text(json.dumps({
"branches": [{"name": "TESTBRANCH", "email": "@testbranch", "path": str(branch_path)}]
}), encoding="utf-8")
monkeypatch.setattr(wake_mod, "_REPO_ROOT", tmp_path)
monkeypatch.setattr(wake_mod, "BRANCH_REGISTRY", registry_file)
monkeypatch.setattr(wake_mod, "PAUSE_FILE", tmp_path / ".aipass" / "autonomous_pause")
monkeypatch.setattr(wake_mod, "CONFIG_FILE", tmp_path / "safety_config.json")
monkeypatch.setattr(wake_mod, "MONITOR_SCRIPT", tmp_path / "dispatch_monitor.py")
(tmp_path / "dispatch_monitor.py").touch()
# Strip ~/.local/bin from os.environ to simulate restricted PATH
from pathlib import Path as _Path
local_bin = str(_Path.home() / ".local" / "bin")
monkeypatch.setenv("PATH", "/usr/bin:/bin")
captured_envs: list = []
def fake_popen(cmd, **kwargs):
"""Capture spawn_env without launching a real process."""
captured_envs.append(kwargs.get("env", {}))
class FakeProc:
pid = 99999
return FakeProc()
monkeypatch.setattr("subprocess.Popen", fake_popen)
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: True)
monkeypatch.setattr(wake_mod, "_clean_zombies", lambda: 0)
monkeypatch.setattr(wake_mod, "_is_branch_occupied", lambda p: False)
monkeypatch.setattr(wake_mod, "_acquire_lock", lambda p, pid: (True, "ok"))
monkeypatch.setattr("aipass.ai_mail.apps.handlers.dispatch.wake.time.sleep", lambda _: None)
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.notify.send_notification",
lambda *a, **kw: None,
raising=False,
)
wake_mod.wake_branch("@testbranch", fresh=True)
assert captured_envs, "Popen was not called"
env = captured_envs[0]
assert local_bin in env.get("PATH", ""), (
f"~/.local/bin not in spawn_env PATH: {env.get('PATH', '')}"
)