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
355 lines
13 KiB
Python
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,
|
|
)
|