diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 4ef1d80c9cd..090f23a2c09 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -121,8 +121,7 @@ public async Task SendAsync(HttpRequestInfo request, Cancella throw new ArgumentException("Request method must be provided.", nameof(request)); } - HttpRequestInfo currentRequest = request; - Uri currentUri = CreateAbsoluteUri(ResolveRequestUri(request)); + HttpRequestInfo currentRequest = CreateCanonicalRequestInfo(request, out Uri currentUri); using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) @@ -306,6 +305,21 @@ private static Uri CreateAbsoluteUri(string requestUri) return uri; } + private static HttpRequestInfo CreateCanonicalRequestInfo(HttpRequestInfo request, out Uri uri) + { + uri = CreateAbsoluteUri(ResolveRequestUri(request)); + return new HttpRequestInfo + { + Method = request.Method, + Url = uri.ToString(), + Headers = request.Headers, + BodyContentType = request.BodyContentType, + Body = request.Body, + Timeout = request.Timeout, + ConnectionName = request.ConnectionName, + }; + } + private static bool TryCreateRedirectRequest( HttpResponseMessage response, HttpRequestInfo currentRequest, diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index d4669923a84..2d0cfbc8c1c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -895,6 +895,49 @@ public async Task SendAsyncProviderClientAllowsScopedDefaultHeadersOnInitialRequ Assert.Equal(1, providerCallCount); } + [Fact] + public async Task SendAsyncInvokesProviderWithCanonicalRequestUriAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + List providerUrls = []; + List requestUrls = []; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + }; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestUrls.Add(req.RequestUri!.ToString()); + return Task.FromResult(okResponse); + }); +#pragma warning restore CA2025 + using HttpClient providerClient = new(messageHandler); +#pragma warning disable CA2025 + await using DefaultHttpRequestHandler handler = new((info, _) => + { + providerUrls.Add(info.Url); + return Task.FromResult(providerClient); + }); +#pragma warning restore CA2025 + + HttpRequestInfo request = new() + { + Method = "GET", + Url = "https://api.example.test/public/../admin/private", + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert - the provider must authorize the same canonical URI that reaches + // HttpClient, not the raw dot-segment URL from the workflow definition. + Assert.Equal("ok", result.Body); + Assert.Equal(["https://api.example.test/admin/private"], requestUrls); + Assert.Equal(requestUrls, providerUrls); + } + [Fact] public async Task SendAsyncSuppliedClientReturnsRedirectResponseAsync() {