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

373 lines
12 KiB
Python

"""Shared LLM auth setup operations for CLI, wizard, and REPL wrappers."""
from __future__ import annotations
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from config.llm_auth.auth_method import OAUTH_AUTH_METHOD
from config.llm_auth.credentials import (
delete as delete_provider_auth,
)
from config.llm_auth.credentials import (
save_api_key,
)
from config.llm_auth.credentials import (
status as provider_auth_status,
)
from config.llm_auth.credentials import (
verify as verify_provider_auth,
)
from config.llm_auth.records import (
delete_provider_auth_record,
resolve_provider_auth_record,
save_provider_auth_record,
)
from config.llm_credentials import (
save_llm_api_key,
)
from integrations.llm_cli.codex_oauth import CodexOAuthError, run_codex_oauth_login
from surfaces.cli.llm_auth.providers import (
ProviderAuthProfile,
provider_for_profile,
resolve_auth_profile,
)
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE, ProviderOption
from surfaces.cli.wizard.env_sync import sync_provider_env
from surfaces.cli.wizard.validation import validate_provider_credentials
class AuthSetupError(RuntimeError):
"""Raised when provider auth setup cannot complete."""
SaveSecret = Callable[[str, str], None]
@dataclass(frozen=True)
class AuthStatus:
"""Status row for one provider auth path."""
provider: str
label: str
authenticated: bool
source: str
detail: str
verified: bool = False
stale: bool = False
@dataclass(frozen=True)
class AuthSetupResult:
"""Result from configuring a provider auth path."""
provider: str
model: str
source: str
detail: str
env_path: Path | None
def _save_auth_record(
*,
provider: ProviderOption,
profile: ProviderAuthProfile,
source: str,
detail: str,
) -> None:
save_provider_auth_record(
provider=provider.value,
auth_name=profile.name,
kind=profile.kind,
source=source,
detail=detail,
)
def persist_api_key_secret(
env_var: str,
value: str,
*,
save_secret: SaveSecret = save_llm_api_key,
) -> None:
"""Persist one API-key secret through the shared auth service boundary."""
try:
save_secret(env_var, value)
except RuntimeError as exc:
raise AuthSetupError(str(exc)) from exc
def configure_api_key_provider(
*,
profile: ProviderAuthProfile,
api_key: str,
model: str | None = None,
set_provider: bool = True,
validate: bool = True,
env_path: Path | None = None,
) -> AuthSetupResult:
"""Validate and persist an API-key provider credential."""
provider = provider_for_profile(profile)
if provider.credential_kind != "api_key" or not provider.api_key_env:
raise AuthSetupError(f"{provider.label} does not use an OpenSRE-managed API key.")
normalized_key = api_key.strip()
if not normalized_key:
raise AuthSetupError(f"{provider.api_key_env} cannot be empty.")
selected_model = (model if model is not None else provider.default_model).strip()
if validate:
validation = validate_provider_credentials(
provider=provider,
api_key=normalized_key,
model=selected_model,
)
if not validation.ok:
raise AuthSetupError(validation.detail)
try:
save_api_key(
provider.value,
normalized_key,
detail=f"{provider.api_key_env} stored in the system keychain.",
)
except (RuntimeError, ValueError) as exc:
raise AuthSetupError(str(exc)) from exc
written_path = (
sync_provider_env(provider=provider, model=selected_model, env_path=env_path)
if set_provider
else None
)
detail = f"{provider.api_key_env} stored in the system keychain."
_save_auth_record(provider=provider, profile=profile, source="keyring", detail=detail)
return AuthSetupResult(
provider=provider.value,
model=selected_model,
source="keyring",
detail=detail,
env_path=written_path,
)
def _managed_codex_login_detail() -> str:
try:
result = run_codex_oauth_login()
except CodexOAuthError as exc:
raise AuthSetupError(str(exc)) from exc
return result.detail
def _subscription_login_command(profile: ProviderAuthProfile, binary_path: str) -> list[str]:
if profile.provider_value == "codex":
return [binary_path, "login"]
if profile.provider_value == "claude-code":
return [binary_path, "auth", "login"]
raise AuthSetupError(f"No interactive login command is registered for {profile.label}.")
def _run_vendor_login(profile: ProviderAuthProfile, binary_path: str) -> None:
try:
result = subprocess.run(_subscription_login_command(profile, binary_path), check=False)
except OSError as exc:
raise AuthSetupError(f"Could not launch {profile.label} login: {exc}") from exc
if result.returncode != 0:
raise AuthSetupError(f"{profile.label} login exited with code {result.returncode}.")
def configure_cli_subscription_provider(
*,
profile: ProviderAuthProfile,
model: str | None = None,
set_provider: bool = True,
launch_login: bool = True,
env_path: Path | None = None,
) -> AuthSetupResult:
"""Configure a CLI-backed subscription provider such as ChatGPT/Codex or Claude Code."""
provider = provider_for_profile(profile)
if provider.credential_kind != "cli" or provider.adapter_factory is None:
raise AuthSetupError(f"{provider.label} is not a CLI-backed subscription provider.")
adapter = provider.adapter_factory()
probe = adapter.detect()
if not probe.installed:
raise AuthSetupError(f"{probe.detail} Install: {adapter.install_hint}")
login_completed = False
if probe.logged_in is not True:
if profile.provider_value == "codex" and launch_login:
detail = _managed_codex_login_detail()
selected_model = (model if model is not None else provider.default_model).strip()
public_provider = PROVIDER_BY_VALUE["openai"]
written_path = (
sync_provider_env(
provider=public_provider,
model=selected_model,
model_provider=provider,
auth_method=OAUTH_AUTH_METHOD,
env_path=env_path,
)
if set_provider
else None
)
_save_auth_record(
provider=provider,
profile=profile,
source="codex-oauth",
detail=detail,
)
return AuthSetupResult(
provider=provider.value,
model=selected_model,
source="codex-oauth",
detail=detail,
env_path=written_path,
)
if launch_login and probe.bin_path:
_run_vendor_login(profile, probe.bin_path)
login_completed = True
probe = adapter.detect()
if probe.logged_in is not True and not login_completed:
raise AuthSetupError(f"{probe.detail} {adapter.auth_hint}")
selected_model = (model if model is not None else provider.default_model).strip()
written_path = (
sync_provider_env(provider=provider, model=selected_model, env_path=env_path)
if set_provider
else None
)
detail = (
f"{provider.label} login completed via {adapter.auth_hint.replace('Run: ', '')}."
if login_completed and probe.logged_in is not True
else probe.detail or f"{provider.label} is authenticated."
)
_save_auth_record(provider=provider, profile=profile, source="vendor-cli", detail=detail)
return AuthSetupResult(
provider=provider.value,
model=selected_model,
source="vendor-cli",
detail=detail,
env_path=written_path,
)
def provider_status(raw_name: str) -> AuthStatus:
"""Return auth status for an auth profile or provider alias."""
profile = resolve_auth_profile(raw_name)
provider = provider_for_profile(profile)
record = resolve_provider_auth_record(provider.value)
if profile.kind == "api_key":
resolved = provider_auth_status(provider.value)
source = resolved.source
authenticated = resolved.configured and not resolved.stale
detail = resolved.detail
if record.get("detail") and authenticated:
detail = record["detail"]
return AuthStatus(
provider.value,
profile.label,
authenticated,
source,
detail,
verified=resolved.verified,
stale=resolved.stale,
)
record_verified = (record.get("verified") or "").strip().lower()
record_stale = (record.get("stale") or "").strip().lower()
if (
record.get("source") == "codex-oauth"
and record_verified != "false"
and record_stale != "true"
):
return AuthStatus(
provider.value,
profile.label,
True,
"codex-oauth",
record.get("detail") or "OpenAI OAuth tokens are stored for Codex.",
verified=True,
)
if provider.adapter_factory is None:
return AuthStatus(provider.value, profile.label, False, "none", "No adapter registered.")
probe = provider.adapter_factory().detect()
authenticated = probe.installed and probe.logged_in is True
cli_source = "vendor-cli" if authenticated else "none"
detail = probe.detail
if record.get("detail") and authenticated:
detail = record["detail"]
return AuthStatus(
provider.value, profile.label, authenticated, cli_source, detail, verified=authenticated
)
def verify_provider(raw_name: str) -> AuthStatus:
"""Intentionally resolve request-time credentials and refresh metadata."""
profile = resolve_auth_profile(raw_name)
provider = provider_for_profile(profile)
if profile.kind != "api_key":
return provider_status(raw_name)
resolved = verify_provider_auth(provider.value)
return AuthStatus(
provider.value,
profile.label,
resolved.configured and not resolved.stale,
resolved.source,
resolved.detail,
verified=resolved.verified,
stale=resolved.stale,
)
def logout_provider(raw_name: str, *, vendor: bool = False) -> str:
"""Clear OpenSRE-managed auth for a provider.
For API-key providers this deletes the keyring API key. For subscription
CLI providers, OpenSRE clears only its metadata unless ``vendor=True`` is
requested; the actual session belongs to the vendor CLI.
"""
profile = resolve_auth_profile(raw_name)
provider = provider_for_profile(profile)
delete_provider_auth_record(provider.value)
if profile.kind == "api_key":
delete_provider_auth(provider.value)
return f"Removed {provider.api_key_env} from OpenSRE's keyring store."
if not vendor:
return (
f"Cleared OpenSRE auth metadata for {profile.label}. "
f"Vendor CLI session remains; logout with: {profile.auth_hint.replace('login', 'logout')}"
)
if provider.adapter_factory is None:
raise AuthSetupError(f"No adapter is registered for {provider.label}.")
probe = provider.adapter_factory().detect()
if not probe.installed or not probe.bin_path:
raise AuthSetupError(f"{provider.label} CLI is not installed.")
command = profile.auth_hint.replace("Run: ", "").replace("login", "logout").split()
try:
result = subprocess.run([probe.bin_path, *command[1:]], check=False)
except OSError as exc:
raise AuthSetupError(f"Could not launch vendor logout: {exc}") from exc
if result.returncode != 0:
raise AuthSetupError(f"Vendor logout exited with code {result.returncode}.")
return f"Logged out of {profile.label} via vendor CLI."
__all__ = [
"AuthSetupError",
"AuthSetupResult",
"AuthStatus",
"configure_api_key_provider",
"configure_cli_subscription_provider",
"logout_provider",
"persist_api_key_secret",
"provider_status",
"verify_provider",
]