diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py index 8a2c7253..baecbbb6 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py @@ -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) diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py index 6e4aaf80..f7582b1c 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -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) diff --git a/src/aipass/ai_mail/tests/test_wake.py b/src/aipass/ai_mail/tests/test_wake.py index e5d0e214..d58e0756 100644 --- a/src/aipass/ai_mail/tests/test_wake.py +++ b/src/aipass/ai_mail/tests/test_wake.py @@ -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', '')}" + )