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
353 lines
12 KiB
Python
353 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import keyring
|
|
import keyring.errors
|
|
import pytest
|
|
from keyring.backend import KeyringBackend
|
|
|
|
from config.llm_auth.credentials import resolve_for_request, status
|
|
from config.llm_auth.records import resolve_provider_auth_record, save_provider_auth_record
|
|
from config.llm_credentials import resolve_llm_api_key
|
|
from integrations.llm_cli.base import CLIProbe
|
|
from integrations.llm_cli.codex_oauth import CodexOAuthResult
|
|
from surfaces.cli.llm_auth.providers import ProviderAuthProfile, resolve_auth_profile
|
|
from surfaces.cli.llm_auth.service import (
|
|
AuthSetupError,
|
|
configure_api_key_provider,
|
|
configure_cli_subscription_provider,
|
|
)
|
|
from surfaces.cli.wizard.config import ModelOption, ProviderOption
|
|
from surfaces.cli.wizard.validation import ValidationResult
|
|
from tests.shared.keyring_backend import MemoryKeyring
|
|
|
|
|
|
def test_resolve_auth_profile_accepts_subscription_aliases() -> None:
|
|
assert resolve_auth_profile("chatgpt").provider_value == "codex"
|
|
assert resolve_auth_profile("openai-codex").provider_value == "codex"
|
|
assert resolve_auth_profile("claude").provider_value == "claude-code"
|
|
assert resolve_auth_profile("deepseek").provider_value == "deepseek"
|
|
|
|
|
|
def test_configure_deepseek_api_key_stores_keyring_and_nonsecret_env(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.delenv("OPENSRE_DISABLE_KEYRING", raising=False)
|
|
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
|
monkeypatch.setenv("OPENSRE_LLM_AUTH_METADATA_PATH", str(tmp_path / "llm-auth.json"))
|
|
monkeypatch.setattr(
|
|
"surfaces.cli.wizard.store.get_store_path",
|
|
lambda: tmp_path / "opensre.json",
|
|
)
|
|
monkeypatch.setattr(
|
|
"surfaces.cli.llm_auth.service.validate_provider_credentials",
|
|
lambda **_kwargs: ValidationResult(ok=True, detail="ok"),
|
|
)
|
|
|
|
previous_backend = keyring.get_keyring()
|
|
keyring.set_keyring(MemoryKeyring())
|
|
try:
|
|
env_path = tmp_path / ".env"
|
|
result = configure_api_key_provider(
|
|
profile=resolve_auth_profile("deepseek"),
|
|
api_key="deepseek-secret",
|
|
model="deepseek-v4-flash",
|
|
env_path=env_path,
|
|
)
|
|
|
|
assert result.provider == "deepseek"
|
|
assert resolve_llm_api_key("DEEPSEEK_API_KEY") == "deepseek-secret"
|
|
env_content = env_path.read_text(encoding="utf-8")
|
|
assert "LLM_PROVIDER=deepseek\n" in env_content
|
|
assert "DEEPSEEK_REASONING_MODEL=deepseek-v4-flash\n" in env_content
|
|
assert "DEEPSEEK_API_KEY=" not in env_content
|
|
assert resolve_provider_auth_record("deepseek")["source"] == "keyring"
|
|
finally:
|
|
keyring.set_keyring(previous_backend)
|
|
|
|
|
|
def test_configure_api_key_does_not_store_when_validation_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.delenv("OPENSRE_DISABLE_KEYRING", raising=False)
|
|
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
|
monkeypatch.setenv("OPENSRE_LLM_AUTH_METADATA_PATH", str(tmp_path / "llm-auth.json"))
|
|
monkeypatch.setattr(
|
|
"surfaces.cli.llm_auth.service.validate_provider_credentials",
|
|
lambda **_kwargs: ValidationResult(ok=False, detail="rejected"),
|
|
)
|
|
|
|
previous_backend = keyring.get_keyring()
|
|
keyring.set_keyring(MemoryKeyring())
|
|
try:
|
|
with pytest.raises(AuthSetupError, match="rejected"):
|
|
configure_api_key_provider(
|
|
profile=resolve_auth_profile("deepseek"),
|
|
api_key="bad-key",
|
|
)
|
|
assert resolve_llm_api_key("DEEPSEEK_API_KEY") == ""
|
|
finally:
|
|
keyring.set_keyring(previous_backend)
|
|
|
|
|
|
class _FakeAdapter:
|
|
name = "fake"
|
|
binary_env_key = "FAKE_BIN"
|
|
install_hint = "install fake"
|
|
auth_hint = "Run: fake login"
|
|
min_version = None
|
|
default_exec_timeout_sec = 30.0
|
|
|
|
def detect(self) -> CLIProbe:
|
|
return CLIProbe(
|
|
installed=True,
|
|
version="1.0.0",
|
|
logged_in=True,
|
|
bin_path="/usr/bin/fake",
|
|
detail="Logged in.",
|
|
)
|
|
|
|
|
|
class _InconclusiveCodexAdapter:
|
|
name = "fake"
|
|
binary_env_key = "FAKE_BIN"
|
|
install_hint = "install fake"
|
|
auth_hint = "Run: fake login"
|
|
min_version = None
|
|
default_exec_timeout_sec = 30.0
|
|
|
|
def detect(self) -> CLIProbe:
|
|
return CLIProbe(
|
|
installed=True,
|
|
version="1.0.0",
|
|
logged_in=None,
|
|
bin_path="/usr/bin/fake",
|
|
detail="Login status unknown.",
|
|
)
|
|
|
|
|
|
def test_configure_cli_subscription_syncs_provider(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.delenv("OPENSRE_DISABLE_KEYRING", raising=False)
|
|
monkeypatch.setenv("OPENSRE_LLM_AUTH_METADATA_PATH", str(tmp_path / "llm-auth.json"))
|
|
monkeypatch.setattr(
|
|
"surfaces.cli.wizard.store.get_store_path",
|
|
lambda: tmp_path / "opensre.json",
|
|
)
|
|
fake_provider = ProviderOption(
|
|
value="codex",
|
|
label="OpenAI Codex CLI",
|
|
group="Local CLI providers",
|
|
api_key_env="",
|
|
model_env="CODEX_MODEL",
|
|
default_model="",
|
|
models=(ModelOption(value="", label="default"),),
|
|
credential_kind="cli",
|
|
adapter_factory=_FakeAdapter,
|
|
allow_custom_models=True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"surfaces.cli.llm_auth.service.provider_for_profile", lambda _profile: fake_provider
|
|
)
|
|
|
|
previous_backend = keyring.get_keyring()
|
|
keyring.set_keyring(MemoryKeyring())
|
|
try:
|
|
env_path = tmp_path / ".env"
|
|
result = configure_cli_subscription_provider(
|
|
profile=ProviderAuthProfile(
|
|
name="chatgpt",
|
|
provider_value="codex",
|
|
label="ChatGPT subscription via Codex CLI",
|
|
kind="cli_subscription",
|
|
),
|
|
model="gpt-5-codex",
|
|
env_path=env_path,
|
|
)
|
|
|
|
assert result.provider == "codex"
|
|
env_content = env_path.read_text(encoding="utf-8")
|
|
assert "LLM_PROVIDER=codex\n" in env_content
|
|
assert "CODEX_MODEL=gpt-5-codex\n" in env_content
|
|
assert resolve_provider_auth_record("codex")["source"] == "vendor-cli"
|
|
finally:
|
|
keyring.set_keyring(previous_backend)
|
|
|
|
|
|
def test_configure_cli_subscription_uses_managed_codex_oauth_when_status_probe_unknown(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.delenv("OPENSRE_DISABLE_KEYRING", raising=False)
|
|
monkeypatch.setenv("OPENSRE_LLM_AUTH_METADATA_PATH", str(tmp_path / "llm-auth.json"))
|
|
monkeypatch.setattr(
|
|
"surfaces.cli.wizard.store.get_store_path",
|
|
lambda: tmp_path / "opensre.json",
|
|
)
|
|
fake_provider = ProviderOption(
|
|
value="codex",
|
|
label="OpenAI Codex CLI",
|
|
group="Local CLI providers",
|
|
api_key_env="",
|
|
model_env="CODEX_MODEL",
|
|
default_model="",
|
|
models=(ModelOption(value="", label="default"),),
|
|
credential_kind="cli",
|
|
adapter_factory=_InconclusiveCodexAdapter,
|
|
allow_custom_models=True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"surfaces.cli.llm_auth.service.provider_for_profile", lambda _profile: fake_provider
|
|
)
|
|
oauth_calls: list[object] = []
|
|
|
|
def _fake_codex_oauth_login() -> CodexOAuthResult:
|
|
oauth_calls.append(None)
|
|
return CodexOAuthResult(
|
|
account_id="account-123",
|
|
auth_path=tmp_path / "codex-home" / "auth.json",
|
|
detail="OpenAI OAuth tokens stored for Codex.",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"surfaces.cli.llm_auth.service.run_codex_oauth_login", _fake_codex_oauth_login
|
|
)
|
|
|
|
previous_backend = keyring.get_keyring()
|
|
keyring.set_keyring(MemoryKeyring())
|
|
try:
|
|
env_path = tmp_path / ".env"
|
|
result = configure_cli_subscription_provider(
|
|
profile=ProviderAuthProfile(
|
|
name="chatgpt",
|
|
provider_value="codex",
|
|
label="ChatGPT subscription via Codex CLI",
|
|
kind="cli_subscription",
|
|
),
|
|
model="gpt-5-codex",
|
|
env_path=env_path,
|
|
)
|
|
|
|
assert oauth_calls == [None]
|
|
assert result.provider == "codex"
|
|
assert result.source == "codex-oauth"
|
|
assert result.detail == "OpenAI OAuth tokens stored for Codex."
|
|
env_content = env_path.read_text(encoding="utf-8")
|
|
assert "LLM_PROVIDER=openai\n" in env_content
|
|
assert "LLM_AUTH_METHOD=oauth\n" in env_content
|
|
assert "CODEX_MODEL=gpt-5-codex\n" in env_content
|
|
assert resolve_provider_auth_record("codex")["source"] == "codex-oauth"
|
|
finally:
|
|
keyring.set_keyring(previous_backend)
|
|
|
|
|
|
class _ErroringKeyring(KeyringBackend):
|
|
"""Simulates a keychain backend that can't be reached (e.g. no D-Bus session)."""
|
|
|
|
priority = 1.0
|
|
|
|
def get_password(self, _service: str, _username: str) -> str | None:
|
|
raise keyring.errors.KeyringError("Secret Service is not reachable")
|
|
|
|
def set_password(self, _service: str, _username: str, _password: str) -> None:
|
|
raise keyring.errors.KeyringError("Secret Service is not reachable")
|
|
|
|
def delete_password(self, _service: str, _username: str) -> None:
|
|
raise keyring.errors.KeyringError("Secret Service is not reachable")
|
|
|
|
|
|
def test_resolve_for_request_does_not_stale_verified_record_on_keyring_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A backend hiccup (regression for #3721) must not downgrade a verified credential.
|
|
|
|
Previously any ``keyring.errors.KeyringError`` during the on-demand keychain
|
|
read was swallowed into an empty string, which read as "credential missing"
|
|
and permanently marked the metadata record stale/unverified — even though
|
|
the secret itself was untouched.
|
|
"""
|
|
monkeypatch.delenv("OPENSRE_DISABLE_KEYRING", raising=False)
|
|
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
|
# OPENSRE_LLM_AUTH_METADATA_PATH is already redirected to this same tmp_path
|
|
# by the autouse ``_isolate_opensre_home_files`` fixture in tests/conftest.py.
|
|
save_provider_auth_record(
|
|
provider="deepseek",
|
|
auth_name="deepseek",
|
|
kind="api_key",
|
|
source="keyring",
|
|
detail="DEEPSEEK_API_KEY stored in the system keychain.",
|
|
verified=True,
|
|
stale=False,
|
|
env_var="DEEPSEEK_API_KEY",
|
|
)
|
|
|
|
previous_backend = keyring.get_keyring()
|
|
keyring.set_keyring(_ErroringKeyring())
|
|
try:
|
|
resolution = resolve_for_request("deepseek")
|
|
|
|
assert resolution.ok is False
|
|
assert "keychain" in resolution.detail.lower()
|
|
|
|
record = resolve_provider_auth_record("deepseek")
|
|
assert record["stale"] == "false"
|
|
assert record["verified"] == "true"
|
|
assert status("deepseek").verified is True
|
|
finally:
|
|
keyring.set_keyring(previous_backend)
|
|
|
|
|
|
def test_resolve_for_request_still_stales_when_credential_is_genuinely_absent(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A clean (error-free) keychain miss is real evidence and should still stale."""
|
|
monkeypatch.delenv("OPENSRE_DISABLE_KEYRING", raising=False)
|
|
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
|
# OPENSRE_LLM_AUTH_METADATA_PATH is already redirected by the autouse
|
|
# ``_isolate_opensre_home_files`` fixture in tests/conftest.py.
|
|
save_provider_auth_record(
|
|
provider="deepseek",
|
|
auth_name="deepseek",
|
|
kind="api_key",
|
|
source="keyring",
|
|
detail="DEEPSEEK_API_KEY stored in the system keychain.",
|
|
verified=True,
|
|
stale=False,
|
|
env_var="DEEPSEEK_API_KEY",
|
|
)
|
|
|
|
previous_backend = keyring.get_keyring()
|
|
keyring.set_keyring(MemoryKeyring())
|
|
try:
|
|
resolution = resolve_for_request("deepseek")
|
|
|
|
assert resolution.ok is False
|
|
record = resolve_provider_auth_record("deepseek")
|
|
assert record["stale"] == "true"
|
|
assert record["verified"] == "false"
|
|
finally:
|
|
keyring.set_keyring(previous_backend)
|
|
|
|
|
|
def test_codex_oauth_metadata_counts_as_prompt_safe_cli_status(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setenv("OPENSRE_LLM_AUTH_METADATA_PATH", str(tmp_path / "llm-auth.json"))
|
|
save_provider_auth_record(
|
|
provider="codex",
|
|
auth_name="chatgpt",
|
|
kind="cli_subscription",
|
|
source="codex-oauth",
|
|
detail="OpenAI OAuth tokens stored for Codex.",
|
|
)
|
|
|
|
from config.llm_auth.credentials import status
|
|
|
|
result = status("codex")
|
|
|
|
assert result.configured is True
|
|
assert result.source == "cli"
|
|
assert result.detail == "OpenAI OAuth tokens stored for Codex."
|