Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

355 lines
13 KiB
Python

"""Anthropic Claude Code CLI adapter (``claude -p``, non-interactive / print mode).
Env vars
--------
CLAUDE_CODE_BIN Optional explicit path to the ``claude`` binary.
Blank or non-runnable paths are ignored; PATH + fallbacks apply.
CLAUDE_CODE_MODEL Optional model override (e.g. ``claude-opus-4-7``).
Unset or empty → omit ``--model``; CLI default applies.
CLAUDE_CODE_TIMEOUT_SECONDS Optional invocation timeout override in seconds for long prompts
(default: 120, min: 30, max: 600).
Auth
----
When the ``claude`` binary is available, OpenSRE probes ``claude auth status``
and treats Claude subscription login as first-class auth. ``ANTHROPIC_API_KEY``
and ``~/.claude/.credentials.json`` (under ``Path.home()`` on all platforms)
are used as fallbacks when the binary is unavailable.
Platforms
---------
Binary resolution uses ``shutil.which`` with ``claude.cmd`` / ``claude.exe`` /
``.bat`` / ``.ps1`` on Windows, plus npm / Volta / pnpm style fallback dirs
(see ``default_cli_fallback_paths``). Without the CLI binary, macOS Keychain
may still hold OAuth credentials, so auth is reported as unclear until the
binary runs; Linux and Windows without env or creds file → not authenticated.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import (
resolve_cli_binary,
)
from integrations.llm_cli.constants import (
DEFAULT_EXEC_TIMEOUT_SEC as _DEFAULT_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MAX_EXEC_TIMEOUT_SEC as _MAX_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MIN_EXEC_TIMEOUT_SEC as _MIN_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.env_overrides import (
ANTHROPIC_CLI_ENV_KEYS,
nonempty_env_values,
)
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
from integrations.llm_cli.timeout_utils import resolve_timeout_from_env
# Claude Code's `--version` does config/cache init that can spike past Codex's 3s
# budget on cold starts or when another claude process holds shared state.
_PROBE_TIMEOUT_SEC = 8.0
_AUTH_HINT = "Run: claude auth login or set ANTHROPIC_API_KEY."
def _resolve_exec_timeout_seconds() -> float:
return resolve_timeout_from_env(
env_key="CLAUDE_CODE_TIMEOUT_SECONDS",
default=_DEFAULT_EXEC_TIMEOUT_SEC,
minimum=_MIN_EXEC_TIMEOUT_SEC,
maximum=_MAX_EXEC_TIMEOUT_SEC,
)
def _anthropic_env_overrides() -> dict[str, str]:
"""Build Claude subprocess auth/config overrides used by probe and invoke."""
env: dict[str, str] = {"NO_COLOR": "1"}
env.update(nonempty_env_values(ANTHROPIC_CLI_ENV_KEYS))
return env
def _anthropic_auth_env_source() -> str | None:
"""Return the active Anthropic auth env key, if present."""
env = _anthropic_env_overrides()
if env.get("ANTHROPIC_API_KEY"):
return "ANTHROPIC_API_KEY"
if env.get("ANTHROPIC_AUTH_TOKEN"):
return "ANTHROPIC_AUTH_TOKEN"
return None
def _auth_status_from_json_payload(data: dict) -> tuple[bool, str]:
"""Map `claude auth status` JSON object to (logged_in, user-facing detail).
The CLI emits ``apiKeySource`` whenever the env contributes an API key,
even when the active auth method is the subscription. Use ``authMethod``
(``claude.ai`` / ``api_key`` / ``none``) as the authoritative discriminator
for the detail string; ``apiKeySource`` and ``email`` are supporting detail
and are also used as a legacy fallback for older CLI versions that omit
``authMethod``.
"""
if not data.get("loggedIn"):
return False, f"Not authenticated. {_AUTH_HINT}"
auth_method = str(data.get("authMethod") or "").lower()
email = str(data.get("email") or "")
api_key_source = str(data.get("apiKeySource") or "")
if auth_method == "api_key":
source = api_key_source or "ANTHROPIC_API_KEY"
return True, f"Authenticated via {source}."
if auth_method == "claude.ai":
return True, f"Authenticated via Claude subscription{f' ({email})' if email else ''}."
if auth_method:
# Unrecognized but non-empty authMethod (e.g. a future "oauth" / "sso"):
# surface it verbatim instead of leaning on apiKeySource, which the CLI
# also populates for env-supplied API keys regardless of the active
# method and would mis-report the source.
return True, f"Authenticated via {auth_method}{f' ({email})' if email else ''}."
# Older CLI versions may omit authMethod — fall back to legacy heuristic.
if api_key_source:
return True, f"Authenticated via {api_key_source}."
if email:
return True, f"Authenticated via Claude subscription ({email})."
return True, "Authenticated via Claude CLI."
def _try_parse_auth_status_stdout(stdout: str) -> tuple[bool, str] | None:
"""If stdout is a JSON object from ``auth status``, return auth; else None."""
raw = (stdout or "").strip()
if not raw:
return None
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(data, dict):
return None
return _auth_status_from_json_payload(data)
def _probe_cli_auth(binary_path: str) -> tuple[bool | None, str]:
"""Check Claude Code auth via `claude auth status` (local, no API call).
Returns ``(True, …)`` for any working auth (subscription or API key),
``(False, …)`` when the CLI definitively reports the user as not logged
in, and ``(None, …)`` only when the probe itself failed (timeout, spawn
error, or unparseable output from an older CLI).
The CLI exits **non-zero** when ``loggedIn`` is false (CLI ≥ 2.x) while
still printing valid JSON, so we parse stdout first and only fall back to
treating a non-zero exit as an opaque probe failure when the JSON is
absent or unparseable. The previous behaviour of returning ``None`` on
any non-zero exit hid the real "not logged in" state behind the wizard's
"could not verify" branch.
"""
try:
proc = subprocess.run(
[binary_path, "auth", "status"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_PROBE_TIMEOUT_SEC,
check=False,
env=build_cli_subprocess_env(_anthropic_env_overrides()),
)
except subprocess.TimeoutExpired:
return (
None,
f"claude auth status timed out after {_PROBE_TIMEOUT_SEC:.0f} s — auth state unknown.",
)
except OSError as exc:
return None, f"Could not spawn claude for auth probe: {exc}"
parsed = _try_parse_auth_status_stdout(proc.stdout)
if parsed is not None:
return parsed
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()[:500]
return None, f"claude auth status failed: {err or 'unknown error'}"
# Older CLI versions may not output JSON; classify explicit negative
# phrases first to avoid false positives like "Not logged in" (exit 0).
plain = (proc.stdout or proc.stderr or "").strip().lower()
negative_markers = (
"not logged in",
"not authenticated",
"login required",
"unauthenticated",
)
if any(marker in plain for marker in negative_markers):
return False, f"Not authenticated. {_AUTH_HINT}"
return True, "Authenticated via Claude CLI."
def _classify_claude_code_auth(binary_path: str | None = None) -> tuple[bool | None, str]:
"""Return (logged_in, detail) for Claude Code auth.
Resolution order:
1. Binary available → `claude auth status` is the source of truth for all
platforms; covers both subscription login and ANTHROPIC_API_KEY.
2. No binary, ANTHROPIC_API_KEY set → True (filesystem-independent fallback).
3. No binary, credentials file present → True (OAuth login).
4. No binary, macOS → None (Keychain may hold credentials; invocation will verify).
5. No binary, Linux/Windows → False.
"""
if binary_path:
return _probe_cli_auth(binary_path)
auth_env_source = _anthropic_auth_env_source()
if auth_env_source:
return True, f"Authenticated via {auth_env_source}."
creds_path = Path.home() / ".claude" / ".credentials.json"
try:
if creds_path.exists() and creds_path.stat().st_size > 2:
return True, "Authenticated via ~/.claude/.credentials.json (OAuth login)."
except OSError:
return None, "Could not read ~/.claude/.credentials.json; auth state unclear."
if sys.platform == "darwin":
return None, (f"Auth state unclear — binary unavailable for verification. {_AUTH_HINT}")
return (
False,
f"Not authenticated. {_AUTH_HINT}",
)
def _fallback_claude_code_paths() -> list[str]:
return _default_cli_fallback_paths("claude")
class ClaudeCodeAdapter:
"""Non-interactive Claude Code CLI (``claude -p``, print mode, no TTY)."""
name = "claude-code"
binary_env_key = "CLAUDE_CODE_BIN"
install_hint = "npm i -g @anthropic-ai/claude-code"
auth_hint = _AUTH_HINT.removesuffix(".")
min_version: str | None = None
default_exec_timeout_sec = _DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="CLAUDE_CODE_BIN",
binary_names=_candidate_binary_names("claude"),
fallback_paths=_fallback_claude_code_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
logged_in, auth_detail = _classify_claude_code_auth(binary_path=binary_path)
auth_env_source = _anthropic_auth_env_source()
if logged_in is not True and auth_env_source:
logged_in = True
auth_detail = f"Authenticated via {auth_env_source} fallback."
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=auth_detail,
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"Claude Code CLI not found on PATH or known install locations. "
f"Install with: {self.install_hint} or set CLAUDE_CODE_BIN."
),
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
_ = reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
f"Claude Code CLI not found. {self.install_hint}"
" or set CLAUDE_CODE_BIN to the full binary path."
)
ws = (workspace or "").strip()
cwd = str(Path(ws).expanduser()) if ws else os.getcwd()
argv: list[str] = [
binary,
"-p",
"--output-format",
"text",
]
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["--model", resolved_model])
# Forward Anthropic auth vars explicitly rather than relying on a blanket
# prefix allowlist, so they don't leak into other CLI adapters (e.g. Codex).
env = _anthropic_env_overrides()
return CLIInvocation(
argv=tuple(argv),
stdin=prompt,
cwd=cwd,
env=env,
timeout_sec=_resolve_exec_timeout_seconds(),
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
return explain_cli_failure(
exit_label="claude -p",
stdout=stdout,
stderr=stderr,
returncode=returncode,
)