From 3cd8b0335ff337cea12d9347a382aff6105bf502 Mon Sep 17 00:00:00 2001 From: abhinav-galileo Date: Fri, 5 Jun 2026 13:40:56 +0530 Subject: [PATCH 1/4] fix: harden auth upstream connection handling --- server/pyproject.toml | 1 + .../auth_framework/config.py | 13 ++ .../auth_framework/providers/http_upstream.py | 116 +++++++++++++++--- server/tests/test_auth_framework.py | 62 ++++++++++ 4 files changed, 177 insertions(+), 15 deletions(-) diff --git a/server/pyproject.toml b/server/pyproject.toml index 212444ac..40c2b0aa 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -9,6 +9,7 @@ requires-python = ">=3.12" # duplicate module conflict when galileo extras are installed dependencies = [ "fastapi>=0.109.0", + "prometheus-client>=0.20.0", "starlette-exporter>=0.23.0", "uvicorn[standard]>=0.27.0", "httpx>=0.27.0", # For auth_framework HTTP providers diff --git a/server/src/agent_control_server/auth_framework/config.py b/server/src/agent_control_server/auth_framework/config.py index 32b6d430..5fd97657 100644 --- a/server/src/agent_control_server/auth_framework/config.py +++ b/server/src/agent_control_server/auth_framework/config.py @@ -50,6 +50,11 @@ _UPSTREAM_TOKEN_HEADER_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN_HEADER" _UPSTREAM_EXTRA_FORWARD_HEADERS_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_EXTRA_FORWARD_HEADERS" _UPSTREAM_CA_FILE_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_CA_FILE" +_UPSTREAM_KEEPALIVE_EXPIRY_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_KEEPALIVE_EXPIRY_SECONDS" +_UPSTREAM_MAX_CONNECTIONS_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_MAX_CONNECTIONS" +_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS_ENV = ( + "AGENT_CONTROL_AUTH_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS" +) # Runtime flow. _RUNTIME_MODE_ENV = "AGENT_CONTROL_RUNTIME_AUTH_MODE" @@ -219,6 +224,11 @@ def _build_default_provider() -> RequestAuthorizer: os.environ.get(_UPSTREAM_EXTRA_FORWARD_HEADERS_ENV) ) ca_file = (os.environ.get(_UPSTREAM_CA_FILE_ENV) or "").strip() or None + keepalive_expiry_seconds = float(os.environ.get(_UPSTREAM_KEEPALIVE_EXPIRY_ENV, "1.0")) + max_connections = int(os.environ.get(_UPSTREAM_MAX_CONNECTIONS_ENV, "20")) + max_keepalive_connections = int( + os.environ.get(_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS_ENV, "5") + ) _logger.info("Default auth provider: http_upstream url=%s", url) try: return HttpUpstreamAuthProvider( @@ -229,6 +239,9 @@ def _build_default_provider() -> RequestAuthorizer: service_token_header=token_header, extra_forward_headers=extra_forward_headers, ca_file=ca_file, + keepalive_expiry_seconds=keepalive_expiry_seconds, + max_connections=max_connections, + max_keepalive_connections=max_keepalive_connections, ) ) except (OSError, ssl.SSLError) as exc: diff --git a/server/src/agent_control_server/auth_framework/providers/http_upstream.py b/server/src/agent_control_server/auth_framework/providers/http_upstream.py index cbd2cb18..33e4f2d4 100644 --- a/server/src/agent_control_server/auth_framework/providers/http_upstream.py +++ b/server/src/agent_control_server/auth_framework/providers/http_upstream.py @@ -44,11 +44,13 @@ import ssl from dataclasses import dataclass from datetime import datetime +from time import perf_counter from typing import Any import httpx from agent_control_models.errors import ErrorCode, ErrorReason from fastapi import Request +from prometheus_client import Counter, Histogram from pydantic import ( BaseModel, ConfigDict, @@ -66,6 +68,17 @@ _DEFAULT_FORWARDED_HEADERS = ("X-API-Key", "Authorization", "Cookie") +_AUTH_UPSTREAM_ATTEMPTS = Counter( + "agent_control_server_auth_upstream_attempts_total", + "Auth upstream HTTP attempts made by Agent Control.", + ("operation", "outcome", "status_code", "error_type"), +) +_AUTH_UPSTREAM_ATTEMPT_DURATION = Histogram( + "agent_control_server_auth_upstream_attempt_duration_seconds", + "Duration of auth upstream HTTP attempts made by Agent Control.", + ("operation", "outcome"), +) + class _UpstreamGrant(BaseModel): """Strict schema for the upstream authorization-service response. @@ -154,7 +167,30 @@ class HttpUpstreamConfig: ca_file: str | None = None """Optional CA bundle path used only when verifying the auth upstream.""" + keepalive_expiry_seconds: float = 1.0 + """Idle lifetime for pooled upstream connections. + + Keep this shorter than the upstream server's own keepalive/recycle window + so Agent Control does not reuse sockets the upstream has already closed. + """ + + max_connections: int = 20 + """Maximum concurrent connections to the auth upstream.""" + + max_keepalive_connections: int = 5 + """Maximum idle connections retained for the auth upstream.""" + def __post_init__(self) -> None: + if self.keepalive_expiry_seconds < 0: + raise ValueError("keepalive_expiry_seconds must be greater than or equal to 0") + if self.max_connections <= 0: + raise ValueError("max_connections must be greater than 0") + if self.max_keepalive_connections < 0: + raise ValueError("max_keepalive_connections must be greater than or equal to 0") + if self.max_keepalive_connections > self.max_connections: + raise ValueError( + "max_keepalive_connections must be less than or equal to max_connections" + ) if self.service_token is None: return forwarded = { @@ -180,14 +216,18 @@ def __init__( self._owns_client = client is None if client is not None: self._client = client - elif config.ca_file is not None: - ssl_context = ssl.create_default_context(cafile=config.ca_file) - self._client = httpx.AsyncClient( - timeout=config.timeout_seconds, - verify=ssl_context, - ) else: - self._client = httpx.AsyncClient(timeout=config.timeout_seconds) + client_kwargs: dict[str, Any] = { + "timeout": config.timeout_seconds, + "limits": httpx.Limits( + max_connections=config.max_connections, + max_keepalive_connections=config.max_keepalive_connections, + keepalive_expiry=config.keepalive_expiry_seconds, + ), + } + if config.ca_file is not None: + client_kwargs["verify"] = ssl.create_default_context(cafile=config.ca_file) + self._client = httpx.AsyncClient(**client_kwargs) async def aclose(self) -> None: """Release the HTTP client if this provider created it.""" @@ -205,6 +245,16 @@ async def authorize( if context: payload["context"] = context + response = await self._post_upstream(operation, payload, headers) + return self._handle_response(response, operation, context) + + async def _post_upstream( + self, + operation: Operation, + payload: dict[str, Any], + headers: dict[str, str], + ) -> httpx.Response: + started = perf_counter() try: response = await self._client.post( self._config.url, @@ -212,20 +262,26 @@ async def authorize( headers=headers, ) except httpx.HTTPError as exc: + _observe_upstream_attempt( + operation, + perf_counter() - started, + outcome="http_error", + error=exc, + ) _logger.warning( "Auth upstream unreachable for operation %s: %s", operation.value, exc, ) - raise APIError( - status_code=503, - error_code=ErrorCode.AUTH_MISCONFIGURED, - reason=ErrorReason.SERVICE_UNAVAILABLE, - detail="Authorization service unavailable.", - hint="Retry the request; if the failure persists, contact the operator.", - ) from exc + raise _authorization_service_unavailable_error() from exc - return self._handle_response(response, operation, context) + _observe_upstream_attempt( + operation, + perf_counter() - started, + outcome="response", + status_code=response.status_code, + ) + return response def _forward_headers(self, request: Request) -> dict[str, str]: headers: dict[str, str] = {} @@ -363,6 +419,36 @@ def _parse_principal(self, response: httpx.Response) -> Principal: ) +def _observe_upstream_attempt( + operation: Operation, + duration_seconds: float, + *, + outcome: str, + status_code: int | None = None, + error: httpx.HTTPError | None = None, +) -> None: + _AUTH_UPSTREAM_ATTEMPTS.labels( + operation=operation.value, + outcome=outcome, + status_code=str(status_code) if status_code is not None else "none", + error_type=type(error).__name__ if error is not None else "none", + ).inc() + _AUTH_UPSTREAM_ATTEMPT_DURATION.labels( + operation=operation.value, + outcome=outcome, + ).observe(duration_seconds) + + +def _authorization_service_unavailable_error() -> APIError: + return APIError( + status_code=503, + error_code=ErrorCode.AUTH_MISCONFIGURED, + reason=ErrorReason.SERVICE_UNAVAILABLE, + detail="Authorization service unavailable.", + hint="Retry the request; if the failure persists, contact the operator.", + ) + + def _ensure_target_context_matches_grant( context: dict[str, Any] | None, principal: Principal, diff --git a/server/tests/test_auth_framework.py b/server/tests/test_auth_framework.py index ac7fa18e..ec3ef141 100644 --- a/server/tests/test_auth_framework.py +++ b/server/tests/test_auth_framework.py @@ -332,11 +332,31 @@ async def test_http_upstream_uses_ca_file_for_owned_client(monkeypatch): await provider.aclose() assert captured["timeout"] == 2.5 + assert "limits" in captured assert captured["cafile"] == "/etc/agent-control/auth-upstream-ca/ca.crt" assert captured["verify"] is captured["ssl_context"] assert captured["closed"] is True +@pytest.mark.asyncio +async def test_http_upstream_uses_connection_limits_for_owned_client(monkeypatch): + captured = _patch_owned_upstream_client(monkeypatch) + + provider = HttpUpstreamAuthProvider( + HttpUpstreamConfig( + url="https://upstream.example/check", + keepalive_expiry_seconds=0.5, + max_connections=7, + max_keepalive_connections=2, + ) + ) + + await provider.aclose() + + assert "limits" in captured + assert captured["closed"] is True + + @pytest.mark.asyncio async def test_http_upstream_forwards_extra_headers(): # Given: a provider configured with an extra header in its forward list @@ -482,6 +502,23 @@ def boom(_request: httpx.Request) -> httpx.Response: assert exc_info.value.status_code == 503 +@pytest.mark.asyncio +async def test_http_upstream_does_not_retry_network_errors(): + calls = 0 + + def factory(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + raise httpx.RemoteProtocolError("server disconnected") + + provider = _build_upstream(factory) + with pytest.raises(APIError) as exc_info: + await provider.authorize(_build_request(), Operation.CONTROL_BINDINGS_WRITE) + + assert exc_info.value.status_code == 503 + assert calls == 1 + + @pytest.mark.asyncio async def test_http_upstream_rejects_malformed_principal(): provider = _build_upstream(lambda req: httpx.Response(200, json={"not_namespace_key": "x"})) @@ -1492,6 +1529,31 @@ async def test_configure_http_upstream_ca_file_env(monkeypatch): await auth_config.teardown_auth() +@pytest.mark.asyncio +async def test_configure_http_upstream_connection_tuning_env(monkeypatch): + from agent_control_server.auth_framework import config as auth_config + + clear_authorizers() + captured = _patch_owned_upstream_client(monkeypatch) + + monkeypatch.setenv("AGENT_CONTROL_AUTH_MODE", "http_upstream") + monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_URL", "https://auth.example.test/check") + monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_KEEPALIVE_EXPIRY_SECONDS", "0.75") + monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_MAX_CONNECTIONS", "11") + monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS", "3") + + try: + auth_config.configure_auth_from_env() + provider = get_authorizer(Operation.CONTROLS_READ) + assert isinstance(provider, HttpUpstreamAuthProvider) + assert provider._config.keepalive_expiry_seconds == 0.75 + assert provider._config.max_connections == 11 + assert provider._config.max_keepalive_connections == 3 + assert "limits" in captured + finally: + await auth_config.teardown_auth() + + @pytest.mark.asyncio async def test_configure_http_upstream_ca_file_env_reports_bad_path(monkeypatch): from agent_control_server.auth_framework import config as auth_config From 27b03bf38a4e91a279981c7733c8f0ebbcf8886e Mon Sep 17 00:00:00 2001 From: abhinav-galileo Date: Fri, 5 Jun 2026 13:47:34 +0530 Subject: [PATCH 2/4] test(server): cover auth upstream connection tuning validation --- server/tests/test_auth_framework.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/tests/test_auth_framework.py b/server/tests/test_auth_framework.py index ec3ef141..c2969c19 100644 --- a/server/tests/test_auth_framework.py +++ b/server/tests/test_auth_framework.py @@ -317,6 +317,26 @@ def test_http_upstream_rejects_extra_forwarded_service_token_header_collision(): ) +@pytest.mark.parametrize( + "config_overrides, match", + [ + ({"keepalive_expiry_seconds": -0.1}, "keepalive_expiry_seconds"), + ({"max_connections": 0}, "max_connections"), + ({"max_keepalive_connections": -1}, "max_keepalive_connections"), + ( + {"max_connections": 2, "max_keepalive_connections": 3}, + "max_keepalive_connections", + ), + ], +) +def test_http_upstream_rejects_invalid_connection_tuning(config_overrides, match): + with pytest.raises(ValueError, match=match): + HttpUpstreamConfig( + url="https://upstream.example/check", + **config_overrides, + ) + + @pytest.mark.asyncio async def test_http_upstream_uses_ca_file_for_owned_client(monkeypatch): captured = _patch_owned_upstream_client(monkeypatch) From e9428a469c70ce3112d6084ca1e8cb80fb32174b Mon Sep 17 00:00:00 2001 From: abhinav-galileo Date: Fri, 5 Jun 2026 14:07:19 +0530 Subject: [PATCH 3/4] fix(server): preserve auth upstream connection count defaults --- server/src/agent_control_server/auth_framework/config.py | 4 ++-- .../auth_framework/providers/http_upstream.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/agent_control_server/auth_framework/config.py b/server/src/agent_control_server/auth_framework/config.py index 5fd97657..5d4683da 100644 --- a/server/src/agent_control_server/auth_framework/config.py +++ b/server/src/agent_control_server/auth_framework/config.py @@ -225,9 +225,9 @@ def _build_default_provider() -> RequestAuthorizer: ) ca_file = (os.environ.get(_UPSTREAM_CA_FILE_ENV) or "").strip() or None keepalive_expiry_seconds = float(os.environ.get(_UPSTREAM_KEEPALIVE_EXPIRY_ENV, "1.0")) - max_connections = int(os.environ.get(_UPSTREAM_MAX_CONNECTIONS_ENV, "20")) + max_connections = int(os.environ.get(_UPSTREAM_MAX_CONNECTIONS_ENV, "100")) max_keepalive_connections = int( - os.environ.get(_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS_ENV, "5") + os.environ.get(_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS_ENV, "20") ) _logger.info("Default auth provider: http_upstream url=%s", url) try: diff --git a/server/src/agent_control_server/auth_framework/providers/http_upstream.py b/server/src/agent_control_server/auth_framework/providers/http_upstream.py index 33e4f2d4..b61705b9 100644 --- a/server/src/agent_control_server/auth_framework/providers/http_upstream.py +++ b/server/src/agent_control_server/auth_framework/providers/http_upstream.py @@ -174,10 +174,10 @@ class HttpUpstreamConfig: so Agent Control does not reuse sockets the upstream has already closed. """ - max_connections: int = 20 + max_connections: int = 100 """Maximum concurrent connections to the auth upstream.""" - max_keepalive_connections: int = 5 + max_keepalive_connections: int = 20 """Maximum idle connections retained for the auth upstream.""" def __post_init__(self) -> None: From d5e67b3aab63b5421d945b00d02b07eb550078d7 Mon Sep 17 00:00:00 2001 From: abhinav-galileo Date: Fri, 5 Jun 2026 18:16:39 +0530 Subject: [PATCH 4/4] fix(server): report invalid auth upstream env config --- .../auth_framework/config.py | 90 ++++++++++++--- server/tests/test_auth_framework.py | 109 ++++++++++++++++++ 2 files changed, 182 insertions(+), 17 deletions(-) diff --git a/server/src/agent_control_server/auth_framework/config.py b/server/src/agent_control_server/auth_framework/config.py index 5d4683da..06246a46 100644 --- a/server/src/agent_control_server/auth_framework/config.py +++ b/server/src/agent_control_server/auth_framework/config.py @@ -217,33 +217,42 @@ def _build_default_provider() -> RequestAuthorizer: url = os.environ.get(_UPSTREAM_URL_ENV) if not url: raise RuntimeError(f"{_MODE_ENV}=http_upstream but {_UPSTREAM_URL_ENV} is not set.") - timeout = float(os.environ.get(_UPSTREAM_TIMEOUT_ENV, "5.0")) + timeout = _load_float_env(_UPSTREAM_TIMEOUT_ENV, 5.0) token = os.environ.get(_UPSTREAM_TOKEN_ENV) token_header = os.environ.get(_UPSTREAM_TOKEN_HEADER_ENV, "X-Agent-Control-Service-Token") extra_forward_headers = _parse_extra_forward_headers( os.environ.get(_UPSTREAM_EXTRA_FORWARD_HEADERS_ENV) ) ca_file = (os.environ.get(_UPSTREAM_CA_FILE_ENV) or "").strip() or None - keepalive_expiry_seconds = float(os.environ.get(_UPSTREAM_KEEPALIVE_EXPIRY_ENV, "1.0")) - max_connections = int(os.environ.get(_UPSTREAM_MAX_CONNECTIONS_ENV, "100")) - max_keepalive_connections = int( - os.environ.get(_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS_ENV, "20") + keepalive_expiry_seconds = _load_float_env(_UPSTREAM_KEEPALIVE_EXPIRY_ENV, 1.0) + max_connections = _load_int_env(_UPSTREAM_MAX_CONNECTIONS_ENV, 100) + max_keepalive_connections = _load_int_env(_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS_ENV, 20) + _validate_http_upstream_connection_config( + keepalive_expiry_seconds=keepalive_expiry_seconds, + max_connections=max_connections, + max_keepalive_connections=max_keepalive_connections, ) _logger.info("Default auth provider: http_upstream url=%s", url) try: - return HttpUpstreamAuthProvider( - HttpUpstreamConfig( - url=url, - timeout_seconds=timeout, - service_token=token, - service_token_header=token_header, - extra_forward_headers=extra_forward_headers, - ca_file=ca_file, - keepalive_expiry_seconds=keepalive_expiry_seconds, - max_connections=max_connections, - max_keepalive_connections=max_keepalive_connections, - ) + upstream_config = HttpUpstreamConfig( + url=url, + timeout_seconds=timeout, + service_token=token, + service_token_header=token_header, + extra_forward_headers=extra_forward_headers, + ca_file=ca_file, + keepalive_expiry_seconds=keepalive_expiry_seconds, + max_connections=max_connections, + max_keepalive_connections=max_keepalive_connections, ) + except ValueError as exc: + raise RuntimeError( + "Invalid http_upstream auth configuration from " + f"{_UPSTREAM_TOKEN_HEADER_ENV} or " + f"{_UPSTREAM_EXTRA_FORWARD_HEADERS_ENV}: {exc}" + ) from exc + try: + return HttpUpstreamAuthProvider(upstream_config) except (OSError, ssl.SSLError) as exc: raise RuntimeError( f"{_UPSTREAM_CA_FILE_ENV}={ca_file!r} not found or unreadable." @@ -292,6 +301,53 @@ def _parse_extra_forward_headers(raw: str | None) -> tuple[str, ...]: return tuple(result) +def _load_float_env(env_name: str, default: float) -> float: + raw = os.environ.get(env_name) + if raw is None: + return default + try: + return float(raw) + except ValueError as exc: + raise RuntimeError(f"{env_name}={raw!r} is not a number.") from exc + + +def _load_int_env(env_name: str, default: int) -> int: + raw = os.environ.get(env_name) + if raw is None: + return default + try: + return int(raw) + except ValueError as exc: + raise RuntimeError(f"{env_name}={raw!r} is not an integer.") from exc + + +def _validate_http_upstream_connection_config( + *, + keepalive_expiry_seconds: float, + max_connections: int, + max_keepalive_connections: int, +) -> None: + if keepalive_expiry_seconds < 0: + raise RuntimeError( + f"{_UPSTREAM_KEEPALIVE_EXPIRY_ENV}={keepalive_expiry_seconds} " + "must be greater than or equal to 0." + ) + if max_connections <= 0: + raise RuntimeError( + f"{_UPSTREAM_MAX_CONNECTIONS_ENV}={max_connections} must be greater than 0." + ) + if max_keepalive_connections < 0: + raise RuntimeError( + f"{_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS_ENV}={max_keepalive_connections} " + "must be greater than or equal to 0." + ) + if max_keepalive_connections > max_connections: + raise RuntimeError( + f"{_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS_ENV}={max_keepalive_connections} " + f"must be less than or equal to {_UPSTREAM_MAX_CONNECTIONS_ENV}={max_connections}." + ) + + def _resolve_runtime_mode() -> str: raw = os.environ.get(_RUNTIME_MODE_ENV) if raw is None or not raw.strip(): diff --git a/server/tests/test_auth_framework.py b/server/tests/test_auth_framework.py index c2969c19..c3514fba 100644 --- a/server/tests/test_auth_framework.py +++ b/server/tests/test_auth_framework.py @@ -1574,6 +1574,115 @@ async def test_configure_http_upstream_connection_tuning_env(monkeypatch): await auth_config.teardown_auth() +@pytest.mark.parametrize( + "env_name, raw_value, expected", + [ + ("AGENT_CONTROL_AUTH_UPSTREAM_TIMEOUT_SECONDS", "slow", "not a number"), + ( + "AGENT_CONTROL_AUTH_UPSTREAM_KEEPALIVE_EXPIRY_SECONDS", + "soon", + "not a number", + ), + ("AGENT_CONTROL_AUTH_UPSTREAM_MAX_CONNECTIONS", "many", "not an integer"), + ( + "AGENT_CONTROL_AUTH_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS", + "some", + "not an integer", + ), + ], +) +def test_build_default_provider_reports_invalid_http_upstream_numeric_env( + monkeypatch, + env_name, + raw_value, + expected, +): + from agent_control_server.auth_framework import config as auth_config + + monkeypatch.setenv("AGENT_CONTROL_AUTH_MODE", "http_upstream") + monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_URL", "https://auth.example.test/check") + monkeypatch.setenv(env_name, raw_value) + + with pytest.raises(RuntimeError) as exc_info: + auth_config._build_default_provider() + + message = str(exc_info.value) + assert f"{env_name}={raw_value!r}" in message + assert expected in message + + +@pytest.mark.parametrize( + "env_values, expected_parts", + [ + ( + {"AGENT_CONTROL_AUTH_UPSTREAM_KEEPALIVE_EXPIRY_SECONDS": "-0.1"}, + [ + "AGENT_CONTROL_AUTH_UPSTREAM_KEEPALIVE_EXPIRY_SECONDS=-0.1", + "greater than or equal to 0", + ], + ), + ( + {"AGENT_CONTROL_AUTH_UPSTREAM_MAX_CONNECTIONS": "0"}, + [ + "AGENT_CONTROL_AUTH_UPSTREAM_MAX_CONNECTIONS=0", + "greater than 0", + ], + ), + ( + {"AGENT_CONTROL_AUTH_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS": "-1"}, + [ + "AGENT_CONTROL_AUTH_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS=-1", + "greater than or equal to 0", + ], + ), + ( + { + "AGENT_CONTROL_AUTH_UPSTREAM_MAX_CONNECTIONS": "20", + "AGENT_CONTROL_AUTH_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS": "30", + }, + [ + "AGENT_CONTROL_AUTH_UPSTREAM_MAX_KEEPALIVE_CONNECTIONS=30", + "AGENT_CONTROL_AUTH_UPSTREAM_MAX_CONNECTIONS=20", + ], + ), + ], +) +def test_build_default_provider_reports_invalid_http_upstream_connection_env( + monkeypatch, + env_values, + expected_parts, +): + from agent_control_server.auth_framework import config as auth_config + + monkeypatch.setenv("AGENT_CONTROL_AUTH_MODE", "http_upstream") + monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_URL", "https://auth.example.test/check") + for env_name, value in env_values.items(): + monkeypatch.setenv(env_name, value) + + with pytest.raises(RuntimeError) as exc_info: + auth_config._build_default_provider() + + message = str(exc_info.value) + for part in expected_parts: + assert part in message + + +def test_build_default_provider_wraps_http_upstream_config_errors(monkeypatch): + from agent_control_server.auth_framework import config as auth_config + + monkeypatch.setenv("AGENT_CONTROL_AUTH_MODE", "http_upstream") + monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_URL", "https://auth.example.test/check") + monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN", "secret") + monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN_HEADER", "Authorization") + + with pytest.raises(RuntimeError) as exc_info: + auth_config._build_default_provider() + + message = str(exc_info.value) + assert "Invalid http_upstream auth configuration" in message + assert "AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN_HEADER" in message + + @pytest.mark.asyncio async def test_configure_http_upstream_ca_file_env_reports_bad_path(monkeypatch): from agent_control_server.auth_framework import config as auth_config