chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user