From fc4c176eecdb1081fa6fc4bc654bd59db3489df1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:41:41 -0700 Subject: [PATCH] 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. --- agent/anthropic_adapter.py | 13 ++++ agent/anthropic_credentials.py | 4 +- hermes_cli/auth.py | 8 ++- tests/agent/test_anthropic_adapter.py | 4 +- tests/fixtures/resolution_allowlist.json | 5 -- .../test_external_process_cli_prefixes.py | 70 +++++++++++++++++++ 6 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 tests/hermes_cli/test_external_process_cli_prefixes.py diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index bad91015b3..b359f7b4f7 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -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(): diff --git a/agent/anthropic_credentials.py b/agent/anthropic_credentials.py index 1084f7893f..27742c5b6c 100644 --- a/agent/anthropic_credentials.py +++ b/agent/anthropic_credentials.py @@ -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 diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index adf870a250..e50c29a50d 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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]: diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 929cd633b7..69aa17316f 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -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) diff --git a/tests/fixtures/resolution_allowlist.json b/tests/fixtures/resolution_allowlist.json index 5ee87e3c17..41f37f2824 100644 --- a/tests/fixtures/resolution_allowlist.json +++ b/tests/fixtures/resolution_allowlist.json @@ -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", diff --git a/tests/hermes_cli/test_external_process_cli_prefixes.py b/tests/hermes_cli/test_external_process_cli_prefixes.py new file mode 100644 index 0000000000..6eecc3847f --- /dev/null +++ b/tests/hermes_cli/test_external_process_cli_prefixes.py @@ -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"]]