fix(auth): find the Claude CLI outside PATH in external-process and setup-token checks
_external_process_spec asked PATH only (shutil.which) for a provider's process_command, so a backend started by a LaunchAgent or Hermes Desktop (bare PATH) reported 'claude' missing even when it lived in ~/.local/bin, ~/.claude/local, ... -- the prefixes agent/anthropic_adapter already probes for version detection. run_oauth_setup_token had the same PATH-only lookup. Add anthropic_adapter.find_claude_code_cli(), which resolves a bare claude/claude-code name via hermes_platform's locate_command over the adapter's existing _CLAUDE_CODE_PREFIXES, and use it on a PATH miss in _external_process_spec and in run_oauth_setup_token. PATH hits and every other command are unchanged.
This commit is contained in:
@@ -265,6 +265,19 @@ def _claude_code_candidates() -> List[str]:
|
||||
return list(seen)
|
||||
|
||||
|
||||
def find_claude_code_cli(command: str) -> Optional[str]:
|
||||
"""Path of a bare Claude Code command name on PATH, else in an install prefix; None for any other command.
|
||||
|
||||
Core's own presence checks (external-process providers, ``claude setup-token``) ask this so they
|
||||
agree with version detection about whether the CLI is installed under a GUI/service PATH."""
|
||||
if command not in _CLAUDE_CODE_NAMES:
|
||||
return None
|
||||
from hermes_platform.resolver import locate_command
|
||||
|
||||
found = locate_command(command, known_dirs=_CLAUDE_CODE_PREFIXES).command
|
||||
return found[0] if found else None
|
||||
|
||||
|
||||
def _detect_claude_code_version() -> str:
|
||||
"""Installed Claude Code version (``claude --version``), else the static fallback."""
|
||||
for cmd in _claude_code_candidates():
|
||||
|
||||
@@ -711,8 +711,8 @@ def resolve_anthropic_token(*, model: Optional[str] = None) -> Optional[str]:
|
||||
|
||||
def run_oauth_setup_token() -> Optional[str]:
|
||||
"""Run 'claude setup-token' interactively; the resulting token or None. FileNotFoundError if no 'claude' CLI."""
|
||||
import shutil
|
||||
claude_path = shutil.which("claude")
|
||||
from agent.anthropic_adapter import find_claude_code_cli # late: the adapter imports this module
|
||||
claude_path = find_claude_code_cli("claude")
|
||||
if not claude_path:
|
||||
raise FileNotFoundError("The 'claude' CLI is not installed. Install it with: npm install -g @anthropic-ai/claude-code")
|
||||
# Interactive: stdio inherited so the user can complete the OAuth prompt. noqa: subprocess-stdin
|
||||
|
||||
@@ -2154,7 +2154,13 @@ def _external_process_spec(
|
||||
or str(getattr(profile, "process_command", "") or ""))
|
||||
raw_args = os.getenv(args_env_var, "").strip() if args_env_var else ""
|
||||
args = shlex.split(raw_args) if raw_args else list(getattr(profile, "process_args", ()) or [])
|
||||
return command, args, base_url, shutil.which(command) if command else None, command_env_vars
|
||||
resolved = shutil.which(command) if command else None
|
||||
if command and not resolved:
|
||||
# A GUI/service launch (LaunchAgent, Desktop backend) has a bare PATH: probe Claude Code's
|
||||
# install prefixes as the Anthropic adapter does. Any other command stays PATH-only.
|
||||
from agent.anthropic_adapter import find_claude_code_cli
|
||||
resolved = find_claude_code_cli(command)
|
||||
return command, args, base_url, resolved, command_env_vars
|
||||
|
||||
|
||||
def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]:
|
||||
|
||||
@@ -550,7 +550,7 @@ class TestRunOauthSetupToken:
|
||||
|
||||
def test_returns_token_from_credential_files(self, monkeypatch, tmp_path):
|
||||
"""After subprocess completes, reads credentials from Claude Code files."""
|
||||
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
|
||||
monkeypatch.setattr("agent.anthropic_adapter.find_claude_code_cli", lambda _: "/usr/bin/claude")
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
|
||||
@@ -580,7 +580,7 @@ class TestRunOauthSetupToken:
|
||||
|
||||
def test_returns_none_when_no_creds_found(self, monkeypatch, tmp_path):
|
||||
"""Returns None when subprocess completes but no credentials are found."""
|
||||
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
|
||||
monkeypatch.setattr("agent.anthropic_adapter.find_claude_code_cli", lambda _: "/usr/bin/claude")
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
monkeypatch.setattr("agent.anthropic_credentials.Path.home", lambda: tmp_path)
|
||||
|
||||
5
tests/fixtures/resolution_allowlist.json
vendored
5
tests/fixtures/resolution_allowlist.json
vendored
@@ -9,11 +9,6 @@
|
||||
"symbol": "_claude_code_candidates",
|
||||
"kind": "bare_which"
|
||||
},
|
||||
{
|
||||
"path": "agent/anthropic_credentials.py",
|
||||
"symbol": "run_oauth_setup_token",
|
||||
"kind": "bare_which"
|
||||
},
|
||||
{
|
||||
"path": "agent/lsp/cli.py",
|
||||
"symbol": "_backend_warnings",
|
||||
|
||||
70
tests/hermes_cli/test_external_process_cli_prefixes.py
Normal file
70
tests/hermes_cli/test_external_process_cli_prefixes.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""A Claude Code CLI outside PATH is found by every core check, not only by the Anthropic adapter.
|
||||
|
||||
A backend started by a macOS LaunchAgent or spawned by Hermes Desktop inherits a bare PATH that
|
||||
carries none of Claude Code's install prefixes (``~/.local/bin``, ``~/.claude/local``, ...).
|
||||
``agent.anthropic_adapter`` probes those prefixes after PATH; ``_external_process_spec`` and
|
||||
``run_oauth_setup_token`` asked PATH only, so a provider whose ``process_command`` is ``claude``
|
||||
was reported missing ("Could not find the ... CLI command 'claude'") on a machine where the
|
||||
adapter could run it. Reported against hermes-plugin-claude-subscription-directsdk#32.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
register_provider(
|
||||
ProviderProfile(
|
||||
name="claude-cli-acp-test",
|
||||
display_name="Claude CLI (test)",
|
||||
base_url="process://claude-cli-acp-test",
|
||||
auth_type="external_process",
|
||||
process_command="claude",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _install(directory) -> str:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
exe = directory / "claude"
|
||||
exe.write_text("#!/bin/sh\nexit 0\n")
|
||||
exe.chmod(0o755)
|
||||
return str(exe)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_launch(tmp_path, monkeypatch):
|
||||
"""The user's home holds ``~/.local/bin/claude``; the process PATH carries no install prefix."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "empty-path"))
|
||||
return _install(tmp_path / ".local" / "bin")
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix") # HOME-relative prefixes and the exec bit are the POSIX layout
|
||||
def test_external_process_provider_finds_claude_in_an_install_prefix(service_launch, tmp_path, monkeypatch):
|
||||
from hermes_cli.auth import get_external_process_provider_status, resolve_external_process_provider_credentials
|
||||
|
||||
assert resolve_external_process_provider_credentials("claude-cli-acp-test")["command"] == service_launch
|
||||
status = get_external_process_provider_status("claude-cli-acp-test")
|
||||
assert (status["configured"], status["resolved_command"]) == (True, service_launch)
|
||||
|
||||
# A PATH hit is still what runs: the prefix probe only answers a PATH miss.
|
||||
on_path = _install(tmp_path / "path-bin")
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "path-bin"))
|
||||
assert resolve_external_process_provider_credentials("claude-cli-acp-test")["command"] == on_path
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_setup_token_runs_claude_from_an_install_prefix(service_launch, monkeypatch):
|
||||
import agent.anthropic_credentials as creds
|
||||
|
||||
ran: list[list[str]] = []
|
||||
monkeypatch.setattr(creds.subprocess, "run", lambda argv, *a, **k: ran.append(argv) or subprocess.CompletedProcess(argv, 0))
|
||||
monkeypatch.setattr(creds, "read_claude_code_credentials", lambda: None)
|
||||
|
||||
creds.run_oauth_setup_token()
|
||||
assert ran == [[service_launch, "setup-token"]]
|
||||
Reference in New Issue
Block a user