Files
cleveractors-core/features/steps/credential_envvar_steps.py
hurui200320 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
feat(credentials): refactor LLMAgent/AgentFactory for per-request credential injection and extended provider routing
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
2026-06-08 11:10:46 +00:00

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,
)