Skip to content

Commit 02eba51

Browse files
sellakumaranclaude
andauthored
fix: correct endpoint name derivation for needsDeployment=false and surface exceptions properly (#296)
For non-Azure-hosted agents (dev tunnels, external endpoints), the endpoint name used at deletion time could differ from the name used at registration when WebAppName was also present in config. BotName prioritizes WebAppName, while RegisterBlueprintMessagingEndpointAsync always derives the name from the MessagingEndpoint URL host. This mismatch caused the delete to return "not found", the CLI to proceed, and the subsequent create to fail with "MsaAppId is already in use". - Add EndpointHelper.GetEndpointNameFromUrl() to derive endpoint names from URLs consistently - Use it in UpdateEndpointAsync (BlueprintSubcommand) and CleanupCommand for needsDeployment=false - Extract ResolveEndpointName() helper in CleanupCommand to avoid duplication - Fix --update-endpoint catch block to route Agent365Exception through ExceptionHandler (suppresses stack trace for user errors, honors correct exit code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 66984a6 commit 02eba51

5 files changed

Lines changed: 136 additions & 7 deletions

File tree

‎src/Directory.Packages.props‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
<PackageVersion Include="Azure.ResourceManager.AppService" Version="1.2.0" />
3535
<!-- Microsoft Graph -->
3636
<PackageVersion Include="Microsoft.Graph" Version="5.36.0" />
37+
<!-- Transitive pin: resolves NU1107 conflict between Microsoft.Extensions.Http 9.x (needs >= 9.0.8)
38+
and Microsoft.Kiota.Abstractions 1.7.2 (needs < 9.0.0). CentralPackageTransitivePinningEnabled
39+
allows this pin to override the Kiota upper bound. -->
40+
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.8" />
3741
<!-- Testing packages -->
3842
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
3943
<PackageVersion Include="xunit" Version="2.9.2" />

‎src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -741,7 +741,7 @@ private static async Task<bool> DeleteMessagingEndpointAsync(
741741
}
742742

743743
logger.LogInformation("Deleting messaging endpoint registration...");
744-
var endpointName = config.BotName; // BotName already returns a final, validated name
744+
var endpointName = ResolveEndpointName(config);
745745

746746
var endpointDeleted = await botConfigurator.DeleteEndpointWithAgentBlueprintAsync(
747747
endpointName,
@@ -787,8 +787,8 @@ private static async Task ExecuteEndpointOnlyCleanupAsync(
787787
return;
788788
}
789789

790-
// BotName already returns a final, validated name — no further processing needed
791-
var endpointName = config.BotName;
790+
// Get the actual endpoint name that will be used for deletion (truncated to 42 chars).
791+
var endpointName = ResolveEndpointName(config);
792792

793793
logger.LogInformation("");
794794
logger.LogInformation("Endpoint Cleanup Preview:");
@@ -842,4 +842,23 @@ private static async Task ExecuteEndpointOnlyCleanupAsync(
842842
return null;
843843
}
844844
}
845+
846+
/// <summary>
847+
/// Resolves the Azure Bot Service endpoint name from config.
848+
/// For needsDeployment=false, prefers BotMessagingEndpoint (updated after each registration)
849+
/// over MessagingEndpoint (static) so that delete targets the currently registered endpoint.
850+
/// </summary>
851+
private static string ResolveEndpointName(Agent365Config config)
852+
{
853+
if (!config.NeedDeployment)
854+
{
855+
// Use BotMessagingEndpoint (updated by registration) over MessagingEndpoint (static).
856+
var urlForName = !string.IsNullOrWhiteSpace(config.BotMessagingEndpoint)
857+
? config.BotMessagingEndpoint
858+
: config.MessagingEndpoint;
859+
if (!string.IsNullOrWhiteSpace(urlForName))
860+
return EndpointHelper.GetEndpointNameFromUrl(urlForName, config.AgentBlueprintId);
861+
}
862+
return EndpointHelper.GetEndpointName(config.BotName);
863+
}
845864
}

‎src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,17 @@ await UpdateEndpointAsync(
199199
botConfigurator: botConfigurator,
200200
platformDetector: platformDetector);
201201
}
202+
catch (Agent365Exception ex)
203+
{
204+
var logFilePath = ConfigService.GetCommandLogPath(CommandNames.Setup);
205+
ExceptionHandler.HandleAgent365Exception(ex, logger: logger, logFilePath: logFilePath);
206+
ExceptionHandler.ExitWithCleanup(ex.ExitCode);
207+
}
202208
catch (Exception ex)
203209
{
204-
logger.LogError(ex, "Endpoint update failed: {Message}", ex.Message);
205-
Environment.Exit(1);
210+
logger.LogError("Endpoint update failed: {Message}", ex.Message);
211+
logger.LogDebug(ex, "Endpoint update failed - stack trace");
212+
ExceptionHandler.ExitWithCleanup(1);
206213
}
207214
return;
208215
}
@@ -1821,15 +1828,34 @@ public static async Task UpdateEndpointAsync(
18211828
logger.LogInformation("");
18221829

18231830
// Step 1: Delete existing endpoint if it exists
1824-
if (!string.IsNullOrWhiteSpace(setupConfig.BotName))
1831+
if (!string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint) || !string.IsNullOrWhiteSpace(setupConfig.BotName))
18251832
{
18261833
logger.LogInformation("Deleting existing messaging endpoint...");
18271834
if (string.IsNullOrWhiteSpace(setupConfig.Location))
18281835
{
18291836
logger.LogError("Location not found. Please confirm location is in the config file.");
18301837
throw new Exceptions.SetupValidationException("Location is required to delete the existing messaging endpoint.");
18311838
}
1832-
var endpointName = setupConfig.BotName; // BotName already returns a final, validated name
1839+
1840+
// For needsDeployment=false, derive the endpoint name from the currently registered URL.
1841+
// BotMessagingEndpoint (generated config) is updated after every successful registration,
1842+
// so it reflects the actual registered endpoint name after any --update-endpoint calls.
1843+
// Fall back to MessagingEndpoint (static config) if BotMessagingEndpoint is not yet set.
1844+
string endpointName;
1845+
if (!setupConfig.NeedDeployment && (!string.IsNullOrWhiteSpace(setupConfig.BotMessagingEndpoint) || !string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)))
1846+
{
1847+
var urlForName = !string.IsNullOrWhiteSpace(setupConfig.BotMessagingEndpoint)
1848+
? setupConfig.BotMessagingEndpoint
1849+
: setupConfig.MessagingEndpoint;
1850+
endpointName = Services.Helpers.EndpointHelper.GetEndpointNameFromUrl(urlForName, setupConfig.AgentBlueprintId);
1851+
}
1852+
else
1853+
{
1854+
// When NeedDeployment=true, BotName is always non-empty (derived from WebAppName),
1855+
// so GetEndpointName(BotName) is safe here.
1856+
endpointName = Services.Helpers.EndpointHelper.GetEndpointName(setupConfig.BotName);
1857+
}
1858+
18331859
var normalizedLocation = setupConfig.Location.Replace(" ", "").ToLowerInvariant();
18341860

18351861
var deleted = await botConfigurator.DeleteEndpointWithAgentBlueprintAsync(

‎src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@ public static string GetEndpointName(string name)
3636
return truncated;
3737
}
3838

39+
/// <summary>
40+
/// Derives the Azure Bot Service endpoint name from a messaging URL.
41+
/// Used for non-Azure-hosted agents (needsDeployment=false) to ensure the same
42+
/// name is used consistently across setup, update, and cleanup operations.
43+
/// </summary>
44+
public static string GetEndpointNameFromUrl(string url, string? blueprintId = null)
45+
{
46+
if (string.IsNullOrWhiteSpace(url))
47+
{
48+
throw new SetupValidationException(
49+
"Cannot derive endpoint name: URL must not be null or empty.");
50+
}
51+
52+
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
53+
{
54+
throw new SetupValidationException(
55+
$"Cannot derive endpoint name: '{url}' is not a valid absolute URL.");
56+
}
57+
58+
return GetEndpointNameFromHost(uri.Host, blueprintId);
59+
}
60+
3961
/// <summary>
4062
/// Derives a globally unique endpoint name from a URL hostname and an agent blueprint ID.
4163
/// </summary>

‎src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/EndpointHelperTests.cs‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,48 @@ public void GetEndpointName_WhenResultBecomesTooShort_ShouldThrowSetupValidation
124124
.WithMessage("*becomes too short after processing*");
125125
}
126126

127+
[Fact]
128+
public void GetEndpointNameFromUrl_WithDevTunnelUrl_ReturnsHostBasedName()
129+
{
130+
// Arrange - dev tunnel URL (the customer's scenario)
131+
var url = "https://x23kz7ll-3979-inc1-devtunnels-ms.devtunnels.ms/api/messages";
132+
133+
// Act
134+
var result = EndpointHelper.GetEndpointNameFromUrl(url);
135+
136+
// Assert - full pre-truncation string: "x23kz7ll-3979-inc1-devtunnels-ms-devtunnels-ms-endpoint" (55 chars)
137+
// Truncated to 42 chars: "x23kz7ll-3979-inc1-devtunnels-ms-devtunnel" (no trailing hyphen to strip)
138+
result.Should().Be("x23kz7ll-3979-inc1-devtunnels-ms-devtunnel");
139+
result.Length.Should().Be(42);
140+
}
141+
142+
[Fact]
143+
public void GetEndpointNameFromUrl_WithSimpleUrl_ReturnsExpectedName()
144+
{
145+
// Arrange
146+
var url = "https://myapp.azurewebsites.net/api/messages";
147+
148+
// Act
149+
var result = EndpointHelper.GetEndpointNameFromUrl(url);
150+
151+
// Assert
152+
result.Should().Be("myapp-azurewebsites-net-endpoint");
153+
}
154+
155+
[Fact]
156+
public void GetEndpointNameFromUrl_WithNgrokUrl_ReturnsExpectedName()
157+
{
158+
// Arrange - ngrok free domain
159+
var url = "https://distressingly-gnathonic-alonzo.ngrok-free.app/api/messages";
160+
161+
// Act
162+
var result = EndpointHelper.GetEndpointNameFromUrl(url);
163+
164+
// Assert - same result as going through GetEndpointName with the host-based name
165+
var expected = EndpointHelper.GetEndpointName("distressingly-gnathonic-alonzo-ngrok-free-app-endpoint");
166+
result.Should().Be(expected);
167+
}
168+
127169
// GetEndpointNameFromHost tests
128170

129171
[Fact]
@@ -201,6 +243,22 @@ public void GetEndpointNameFromHost_WithLongHost_TruncatesHostToFitWithinLimit()
201243
result.Length.Should().Be(42);
202244
}
203245

246+
[Theory]
247+
[InlineData(null)]
248+
[InlineData("")]
249+
[InlineData("not-a-url")]
250+
[InlineData("relative/path")]
251+
public void GetEndpointNameFromUrl_WithInvalidUrl_ShouldThrowSetupValidationException(string? input)
252+
{
253+
// Act
254+
// input! suppresses the nullable warning intentionally — tests runtime null/invalid handling
255+
// even though the parameter is declared non-nullable.
256+
Action act = () => EndpointHelper.GetEndpointNameFromUrl(input!);
257+
258+
// Assert
259+
act.Should().Throw<SetupValidationException>();
260+
}
261+
204262
[Theory]
205263
[InlineData(null)]
206264
[InlineData("")]

0 commit comments

Comments
 (0)