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
373 lines
12 KiB
Python
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",
|
|
]
|