f281fa3b24
CI / lint (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 50s
CI / quality (pull_request) Successful in 32s
CI / unit_tests (pull_request) Successful in 3m32s
CI / integration_tests (pull_request) Successful in 59s
CI / build (pull_request) Successful in 34s
CI / lint (push) Successful in 33s
CI / build (push) Successful in 37s
CI / quality (push) Successful in 48s
CI / typecheck (push) Successful in 48s
CI / security (push) Successful in 1m4s
CI / integration_tests (push) Successful in 1m18s
CI / coverage (pull_request) Successful in 3m34s
CI / status-check (pull_request) Successful in 3s
CI / unit_tests (push) Successful in 3m45s
CI / coverage (push) Successful in 3m34s
CI / status-check (push) Successful in 3s
Implements Wave 3 of the cleveractors-core integration (ADR-2026, ADR-2028). ## AgentFactory - Added optional credentials: dict[str, dict[str, str]] | None parameter to __init__. Stored as self.credentials. - When creating LLMAgent instances, the full credentials dict is forwarded via a new credentials constructor kwarg, threading per-request API keys without touching the stored actor config_dict. - Added explicit type annotation ReactiveAgentFactory: type[AgentFactory]. - _create_agent_instance raises ConfigurationError when credentials is not None but does not contain an entry for the requested provider (AC4 compliance). ## LLMAgent - Added optional credentials: dict[str, str] | None parameter to __init__. Stored as self._credentials; config_dict is never modified. - LangChain client construction deferred to first access (lazy init) via a chat_model property backed by _ensure_chat_model(). The property setter allows test-injection of mock models without going through the lazy-init path. - _create_chat_model() dispatches to credential-injection / standalone paths. - Extracted to llm_client.py and llm_providers.py for modularity. - Defined shared module-level constants DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS to eliminate duplicated magic numbers across llm.py, runtime.py, and factory.py. - Added public credentials property exposing self._credentials. - Added type narrowing guard in chat_model property to satisfy static analysis. - Added temperature_override type validation in process_message. - Guarded cleanup() null-assignment with _chat_model_lock (TOCTOU fix). ## SSRF Prevention (ADR-2028) - _validate_base_url(): https-only, userinfo rejection, localhost rejection, raw IP literal rejection, numeric-hostname (dotted-hex/octal/compact) rejection, percent-decode loop with iteration cap (10), trailing-dot FQDN strip. - DNS resolution intentionally omitted (blocking call in async path). - Canonical URL reconstruction uses lowercased scheme. ## Credential Leak Prevention - All logger.exception(...) replaced with logger.debug(..., exc_info=True). - Error messages use type(e).__name__ instead of str(e). - process_message exception chains broken with from None to prevent LangChain authentication errors from leaking via __cause__. ## Runtime fixes - Executor._execute_llm: shallow copy for setdefault mutations. - Executor._execute_graph: restructured try/finally for resource cleanup. - Uses DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS from llm.py. - Defensive copy of credentials dict in Executor.__init__ to prevent caller mutation after construction. ## Test improvements - Added BDD scenarios for llm_imports.py ImportError fallback branches using sys.modules manipulation with a custom import hook, exercised via subprocess. - Added BDD scenario for AgentFactory.create_agents_from_config() credential threading to all created agents. - Removed tautological dataclass test scenarios (NodeUsage, ActorResult) from runtime_coverage.feature. - Fixed misleading scenario title in runtime_coverage.feature (exception chain suppressed, not chained) and updated step to assert __cause__ is None. - Added CHANGELOG entry for ReactiveAgentFactory backward-compatibility alias. - Removed duplicate log emission in _check_known_provider_domain. ISSUES CLOSED: #12
162 lines
5.2 KiB
Python
162 lines
5.2 KiB
Python
"""Step definitions for env-var fallback (standalone mode).
|
|
|
|
Extracted from ``credential_provider_steps.py`` to keep that file
|
|
under 500 lines (CONTRIBUTING.md §General Principles).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
from behave import given, then
|
|
from features.mocks.credential_helpers import make_template_renderer
|
|
|
|
from cleveractors.agents.llm import LLMAgent
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Env-var fallback (standalone mode)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('the OPENAI_API_KEY environment variable is set to "{api_key}"')
|
|
def step_set_openai_env_var(context: Any, api_key: str) -> None:
|
|
"""Set the OPENAI_API_KEY environment variable."""
|
|
context.env_patch = patch.dict(os.environ, {"OPENAI_API_KEY": api_key})
|
|
context.env_patch.start()
|
|
|
|
|
|
@given(
|
|
"I construct an LLMAgent for openai without credentials dict "
|
|
"and without api_key in config"
|
|
)
|
|
def step_openai_agent_no_credentials_no_api_key(context: Any) -> None:
|
|
"""Construct LLMAgent for openai in standalone mode without api_key in config."""
|
|
config: dict[str, Any] = {"provider": "openai", "model": "gpt-3.5-turbo"}
|
|
context.renderer = make_template_renderer()
|
|
context.agent = LLMAgent(
|
|
name="test_openai_agent",
|
|
config=config,
|
|
template_renderer=context.renderer,
|
|
)
|
|
|
|
|
|
@then("the chat_model should be created successfully")
|
|
def step_chat_model_created_successfully(context: Any) -> None:
|
|
"""Verify the chat_model was created (no error) and is a ChatOpenAI instance."""
|
|
from langchain_openai import ChatOpenAI
|
|
|
|
assert context.agent._chat_model is not None, (
|
|
"Expected chat_model to be set after property access"
|
|
)
|
|
assert isinstance(context.agent._chat_model, ChatOpenAI), (
|
|
f"Expected ChatOpenAI instance, got {type(context.agent._chat_model).__name__}"
|
|
)
|
|
|
|
|
|
@given("all API key environment variables are cleared")
|
|
def step_clear_all_api_key_env_vars(context: Any) -> None:
|
|
"""Remove all known API key environment variables."""
|
|
# Stop any leaked patch from a prior scenario before starting a new one.
|
|
if hasattr(context, "env_patch"):
|
|
try:
|
|
context.env_patch.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
keys_to_clear = {
|
|
"OPENAI_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"GOOGLE_API_KEY",
|
|
"GOOGLEAI_API_KEY",
|
|
}
|
|
cleared = {k: "" for k in keys_to_clear}
|
|
context.env_patch = patch.dict(os.environ, cleared)
|
|
context.env_patch.start()
|
|
for key in keys_to_clear:
|
|
os.environ.pop(key, None)
|
|
|
|
|
|
@given(
|
|
'I construct an LLMAgent for provider "{provider}" without credentials dict '
|
|
"and with api_key in config"
|
|
)
|
|
def step_nonnative_agent_standalone_with_api_key(context: Any, provider: str) -> None:
|
|
"""Construct LLMAgent for non-native provider in standalone mode with api_key."""
|
|
config: dict[str, Any] = {
|
|
"provider": provider,
|
|
"model": "test-model",
|
|
"api_key": "standalone-key",
|
|
}
|
|
context.renderer = make_template_renderer()
|
|
context.agent = LLMAgent(
|
|
name="test_openai_agent",
|
|
config=config,
|
|
template_renderer=context.renderer,
|
|
)
|
|
|
|
|
|
@given(
|
|
"I construct an LLMAgent for openai without credentials dict "
|
|
"and with api_key set to 12345"
|
|
)
|
|
def step_openai_agent_standalone_non_string_api_key(context: Any) -> None:
|
|
"""Construct LLMAgent for openai in standalone mode with non-string api_key."""
|
|
# Clear API key env vars so the standalone path doesn't fall back to them
|
|
keys_to_clear = {
|
|
"OPENAI_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"GOOGLE_API_KEY",
|
|
"GOOGLEAI_API_KEY",
|
|
}
|
|
cleared = {k: "" for k in keys_to_clear}
|
|
context.env_patch = patch.dict(os.environ, cleared)
|
|
context.env_patch.start()
|
|
for key in keys_to_clear:
|
|
os.environ.pop(key, None)
|
|
|
|
config: dict[str, Any] = {
|
|
"provider": "openai",
|
|
"model": "gpt-3.5-turbo",
|
|
"api_key": 12345,
|
|
}
|
|
context.renderer = make_template_renderer()
|
|
context.agent = LLMAgent(
|
|
name="test_openai_agent",
|
|
config=config,
|
|
template_renderer=context.renderer,
|
|
)
|
|
|
|
|
|
@given(
|
|
"I construct an LLMAgent for openai without credentials dict "
|
|
"and with whitespace-only api_key in config"
|
|
)
|
|
def step_openai_agent_standalone_whitespace_api_key(context: Any) -> None:
|
|
"""Construct LLMAgent for openai in standalone mode with whitespace-only api_key."""
|
|
# Clear API key env vars so the standalone path doesn't fall back to them
|
|
keys_to_clear = {
|
|
"OPENAI_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"GOOGLE_API_KEY",
|
|
"GOOGLEAI_API_KEY",
|
|
}
|
|
cleared = {k: "" for k in keys_to_clear}
|
|
context.env_patch = patch.dict(os.environ, cleared)
|
|
context.env_patch.start()
|
|
for key in keys_to_clear:
|
|
os.environ.pop(key, None)
|
|
|
|
config: dict[str, Any] = {
|
|
"provider": "openai",
|
|
"model": "gpt-3.5-turbo",
|
|
"api_key": " ",
|
|
}
|
|
context.renderer = make_template_renderer()
|
|
context.agent = LLMAgent(
|
|
name="test_openai_agent",
|
|
config=config,
|
|
template_renderer=context.renderer,
|
|
)
|