From 55afdf6f51cd4f12abd01a64066bf336f83be698 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 16 Jun 2026 10:26:08 -0700 Subject: [PATCH 1/3] workfllow bugfix --- .../InProc/InProcessRunnerContext.cs | 14 ++ .../ExternalResponsePortCorrelationTests.cs | 121 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index 8201f8d8fe2..50c468a5e61 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -154,6 +154,20 @@ public ValueTask AddExternalResponseAsync(ExternalResponse response) async ValueTask PrepareExternalDeliveryAsync() { + if (!this._externalRequests.TryGetValue(response.RequestId, out ExternalRequest? pendingRequest)) + { + throw new InvalidOperationException($"No pending request with ID {response.RequestId} found in the workflow context."); + } + + // Reject responses whose PortInfo.PortId does not match the originating request's port to + // prevent forged routing into unrelated port-specific execution paths. + if (!string.Equals(pendingRequest.PortInfo.PortId, response.PortInfo.PortId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Response port id '{response.PortInfo.PortId}' does not match the originating port id '{pendingRequest.PortInfo.PortId}' for request {response.RequestId}."); + } + + // Consume only after validation so a rejected response leaves the legitimate one able to complete. if (!this.CompleteRequest(response.RequestId)) { throw new InvalidOperationException($"No pending request with ID {response.RequestId} found in the workflow context."); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs new file mode 100644 index 00000000000..901af39203e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Agents.AI.Workflows.Execution; +using Microsoft.Agents.AI.Workflows.InProc; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class ExternalResponsePortCorrelationTests +{ + private const string PortAId = "portA"; + private const string PortBId = "portB"; + private const string SinkId = "sink"; + + private static (Workflow Workflow, RequestPort PortA, RequestPort PortB) BuildTwoPortWorkflow() + { + // Both ports must be registered so a forged PortInfo.PortId is routable past the EdgeMap; + // this isolates the runner-context gate as the only check that can reject the forgery. + RequestPort portA = RequestPort.Create(PortAId); + RequestPort portB = RequestPort.Create(PortBId); + ForwardMessageExecutor sink = new(SinkId); + + Workflow workflow = new WorkflowBuilder(portA) + .AddEdge(portA, sink) + .AddEdge(portB, sink) + .Build(validateOrphans: false); + + return (workflow, portA, portB); + } + + [Fact] + public async Task AddExternalResponseAsync_RejectsForgedPortIdAsync() + { + // Arrange + (Workflow workflow, RequestPort portA, RequestPort portB) = BuildTwoPortWorkflow(); + InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager: null); + + ExternalRequest pending = ExternalRequest.Create(portA, "data"); + await runner.RunContext.PostAsync(pending); + + // Forged: claims portB but reuses portA's RequestId. Identical response types isolate PortId as the only signal. + ExternalResponse forged = new(portB.ToPortInfo(), pending.RequestId, new PortableValue(42)); + + // Act + await runner.RunContext.AddExternalResponseAsync(forged); + + // Assert: validation fires when the queued delivery is drained. + var act = async () => await runner.RunContext.AdvanceAsync(CancellationToken.None); + var exception = await act.Should().ThrowAsync(); + + string message = exception.Which.Message; + message.Should().Contain($"'{PortBId}'").And.Contain($"'{PortAId}'").And.Contain(pending.RequestId); + + // Pending request survives the rejection so the legitimate responder can still complete it. + ((ISuperStepRunner)runner).HasUnservicedRequests.Should().BeTrue(); + } + + [Fact] + public async Task AddExternalResponseAsync_AllowsLegitimateResponseAfterRejectedForgeryAsync() + { + (Workflow workflow, RequestPort portA, RequestPort portB) = BuildTwoPortWorkflow(); + InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager: null); + + ExternalRequest pending = ExternalRequest.Create(portA, "data"); + await runner.RunContext.PostAsync(pending); + + ExternalResponse forged = new(portB.ToPortInfo(), pending.RequestId, new PortableValue(42)); + await runner.RunContext.AddExternalResponseAsync(forged); + + var rejectAct = async () => await runner.RunContext.AdvanceAsync(CancellationToken.None); + await rejectAct.Should().ThrowAsync(); + + // Legitimate responder retries with the correct PortInfo. + ExternalResponse legitimate = pending.CreateResponse(42); + await runner.RunContext.AddExternalResponseAsync(legitimate); + + var legitimateAct = async () => await runner.RunContext.AdvanceAsync(CancellationToken.None); + + await legitimateAct.Should().NotThrowAsync(); + ((ISuperStepRunner)runner).HasUnservicedRequests.Should().BeFalse(); + } + + [Fact] + public async Task AddExternalResponseAsync_AllowsMatchingPortIdAsync() + { + // Baseline: matched-port response is accepted and consumes the pending request. + (Workflow workflow, RequestPort portA, _) = BuildTwoPortWorkflow(); + InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager: null); + + ExternalRequest pending = ExternalRequest.Create(portA, "data"); + await runner.RunContext.PostAsync(pending); + + ExternalResponse legitimate = pending.CreateResponse(42); + + await runner.RunContext.AddExternalResponseAsync(legitimate); + + var act = async () => await runner.RunContext.AdvanceAsync(CancellationToken.None); + await act.Should().NotThrowAsync(); + + ((ISuperStepRunner)runner).HasUnservicedRequests.Should().BeFalse(); + } + + [Fact] + public async Task AddExternalResponseAsync_RejectsUnknownRequestIdAsync() + { + // Regression: unknown RequestId still throws with the original "No pending request" message. + (Workflow workflow, RequestPort portA, _) = BuildTwoPortWorkflow(); + InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager: null); + + ExternalResponse stray = new(portA.ToPortInfo(), "no-such-request", new PortableValue(42)); + + await runner.RunContext.AddExternalResponseAsync(stray); + + var act = async () => await runner.RunContext.AdvanceAsync(CancellationToken.None); + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("No pending request with ID no-such-request"); + } +} From 4006a182f25f52d01df92c699064b9ac33b5465d Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 16 Jun 2026 11:31:33 -0700 Subject: [PATCH 2/3] Update exception message --- .../InProc/InProcessRunnerContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index 50c468a5e61..1a0798ce51f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -164,7 +164,7 @@ async ValueTask PrepareExternalDeliveryAsync() if (!string.Equals(pendingRequest.PortInfo.PortId, response.PortInfo.PortId, StringComparison.Ordinal)) { throw new InvalidOperationException( - $"Response port id '{response.PortInfo.PortId}' does not match the originating port id '{pendingRequest.PortInfo.PortId}' for request {response.RequestId}."); + $"Response port id '{response.PortInfo.PortId}' does not match the originating port id for request {response.RequestId}."); } // Consume only after validation so a rejected response leaves the legitimate one able to complete. From 93741ab3e28521f281a63a03fd98420a31d27a7d Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 16 Jun 2026 13:56:34 -0700 Subject: [PATCH 3/3] Fix unit test. --- .../ExternalResponsePortCorrelationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs index 901af39203e..6d16b5a8a65 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs @@ -52,7 +52,7 @@ public async Task AddExternalResponseAsync_RejectsForgedPortIdAsync() var exception = await act.Should().ThrowAsync(); string message = exception.Which.Message; - message.Should().Contain($"'{PortBId}'").And.Contain($"'{PortAId}'").And.Contain(pending.RequestId); + message.Should().Contain($"'{PortBId}'").And.Contain(pending.RequestId).And.NotContain($"'{PortAId}'"); // Pending request survives the rejection so the legitimate responder can still complete it. ((ISuperStepRunner)runner).HasUnservicedRequests.Should().BeTrue();