Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `a365 publish` updates manifest IDs, creates `manifest.zip`, and prints concise upload instructions for Microsoft 365 Admin Center (Agents > All agents > Upload custom agent). Interactive prompts only occur in interactive terminals; redirect stdin to suppress them in scripts.

### Fixed
- Intermittent `ConnectionResetError (10054)` failures on corporate networks with TLS inspection proxies (Zscaler, Netskope) — Graph and ARM API calls now use direct MSAL.NET token acquisition instead of `az account get-access-token` subprocesses, bypassing the Python HTTP stack that triggered proxy resets (#321)
- `a365 cleanup` blueprint deletion now succeeds for Global Administrators even when the blueprint was created by a different user
- `a365 setup all` no longer times out for non-admin users — the CLI immediately surfaces a consent URL to share with an administrator instead of waiting for a browser prompt
- `a365 setup all` requests admin consent once for all resources instead of prompting once per resource
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ public static async Task<BlueprintCreationResult> CreateBlueprintImplementationA
new GraphApiService(
cleanLoggerFactory.CreateLogger<GraphApiService>(),
executor,
new AuthenticationService(cleanLoggerFactory.CreateLogger<AuthenticationService>()),
graphBaseUrl: setupConfig.GraphBaseUrl));

// Use DI-provided GraphApiService which already has MicrosoftGraphTokenProvider configured
Expand Down Expand Up @@ -761,7 +762,7 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
existingServicePrincipalId = null;
// SP missing for an existing app — attempt creation so downstream steps have a valid SP.
logger.LogInformation("Service principal not found for existing blueprint — attempting to create it...");
var spToken = await graphApiService.GetGraphAccessTokenAsync(tenantId, ct);
var spToken = await graphApiService.GetGraphAccessTokenAsync(tenantId, ct: ct);
if (!string.IsNullOrWhiteSpace(spToken))
{
using var spHttpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(spToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,42 +300,10 @@ public static async Task<bool> ValidateAzureCliAuthenticationAsync(
logger.LogDebug("Azure CLI already authenticated as {LoginHint}", loginHint);
}

// Verify we have the management scope (token is cached at process level by AzCliHelper).
logger.LogDebug("Verifying access to Azure management resources...");
var managementToken = await AzCliHelper.AcquireAzCliTokenAsync(ArmApiService.ArmResource, tenantId);

if (string.IsNullOrWhiteSpace(managementToken))
{
logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication...");
logger.LogInformation("A browser window will open for authentication.");

var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken);

if (!loginResult.Success)
{
logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default");
return false;
}

logger.LogInformation("Azure CLI re-authentication successful!");
AzCliHelper.InvalidateAzCliTokenCache();
await Task.Delay(2000, cancellationToken);

var retryToken = await AzCliHelper.AcquireAzCliTokenAsync(ArmApiService.ArmResource, tenantId);
if (string.IsNullOrWhiteSpace(retryToken))
{
logger.LogWarning("Still unable to acquire management scope token after re-authentication.");
logger.LogWarning("Continuing anyway - you may encounter permission errors later.");
}
else
{
logger.LogDebug("Management scope token acquired successfully!");
}
}
else
{
logger.LogDebug("Management scope verified successfully");
}
// ARM token acquisition is handled lazily by ArmApiService via MSAL (WAM/browser/device-code).
// No eager token pre-fetch is needed here — MSAL will prompt for sign-in when the
// first ARM call is made if no valid cached token exists.
logger.LogDebug("Azure authentication verified. ARM token will be acquired on first resource call.");
return true;
}

Expand Down
1 change: 1 addition & 0 deletions src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini
services.AddSingleton<IConfigService, ConfigService>();
services.AddSingleton<CommandExecutor>();
services.AddSingleton<AuthenticationService>();
services.AddSingleton<IAuthenticationService>(sp => sp.GetRequiredService<AuthenticationService>());
services.AddSingleton<IClientAppValidator, ClientAppValidator>();
services.AddSingleton<IVersionCheckService, VersionCheckService>();
services.AddSingleton<INoticeService, NoticeService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ string GetConfig(string name) =>
try
{
// Use Azure CLI token to get current user (this requires delegated context)
var delegatedToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct);
var delegatedToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: ct);
if (!string.IsNullOrWhiteSpace(delegatedToken))
{
using var delegatedClient = HttpClientFactory.CreateAuthenticatedClient(delegatedToken, correlationId: correlationId);
Expand Down Expand Up @@ -680,7 +680,7 @@ string GetConfig(string name) =>
_logger.LogInformation(" - Agent Identity ID: {Id}", agenticAppId);

// Get Graph access token
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct);
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: ct);
if (string.IsNullOrWhiteSpace(graphToken))
{
_logger.LogError("Failed to acquire Graph API access token");
Expand Down Expand Up @@ -940,7 +940,7 @@ private async Task AssignLicensesAsync(
_logger.LogInformation("Assigning licenses to user {UserId} using Graph API (CorrelationId: {CorrelationId})", userId, correlationId);

// Get Graph access token
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, cancellationToken);
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: cancellationToken);
if (string.IsNullOrWhiteSpace(graphToken))
{
_logger.LogError("Failed to acquire Graph API access token for license assignment");
Expand Down Expand Up @@ -1151,7 +1151,7 @@ private async Task<bool> VerifyServicePrincipalExistsAsync(
try
{
// Use Graph API to check if service principal exists
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct);
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: ct);
if (string.IsNullOrWhiteSpace(graphToken))
{
_logger.LogWarning("Failed to acquire Graph token for service principal verification");
Expand Down
49 changes: 35 additions & 14 deletions src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services;
/// Service for Azure Resource Manager (ARM) existence checks via direct HTTP.
/// Replaces subprocess-based 'az group exists', 'az appservice plan show', and
/// 'az webapp show' calls — each drops from ~15-20s to ~0.5s.
/// Token acquisition is handled by AzCliHelper (process-level cache shared with
/// other services using the management endpoint).
/// Token acquisition uses MSAL via AuthenticationService (WAM on Windows,
/// browser/device-code on macOS/Linux) — no az CLI subprocess involved.
/// </summary>
public class ArmApiService : IDisposable
{
Expand All @@ -27,25 +27,29 @@ public class ArmApiService : IDisposable

private readonly ILogger<ArmApiService> _logger;
private readonly HttpClient _httpClient;
private readonly IAuthenticationService _authService;
private readonly RetryHelper _retryHelper;

// Allow injecting a custom HttpMessageHandler for unit testing.
public ArmApiService(ILogger<ArmApiService> logger, HttpMessageHandler? handler = null)
// Allow injecting a custom HttpMessageHandler and RetryHelper for unit testing.
public ArmApiService(ILogger<ArmApiService> logger, IAuthenticationService authService, HttpMessageHandler? handler = null, RetryHelper? retryHelper = null)
{
_logger = logger;
_authService = authService;
_httpClient = handler != null ? new HttpClient(handler) : HttpClientFactory.CreateAuthenticatedClient();
_retryHelper = retryHelper ?? new RetryHelper(_logger);
}

// Parameterless constructor to ease test mocking/substitution frameworks.
public ArmApiService()
: this(NullLogger<ArmApiService>.Instance, null)
: this(NullLogger<ArmApiService>.Instance, new AuthenticationService(NullLogger<AuthenticationService>.Instance), null)
{
}

public void Dispose() => _httpClient.Dispose();

private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToken ct)
{
var token = await AzCliHelper.AcquireAzCliTokenAsync(ArmResource, tenantId);
var token = await _authService.GetAccessTokenAsync(ArmResource, tenantId);
if (string.IsNullOrWhiteSpace(token))
{
_logger.LogWarning("Unable to acquire ARM access token for tenant {TenantId}", tenantId);
Expand Down Expand Up @@ -74,15 +78,19 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke

try
{
using var response = await _httpClient.GetAsync(url, ct);
using var response = await _retryHelper.ExecuteWithRetryAsync(
ct => _httpClient.GetAsync(url, ct), cancellationToken: ct);
_logger.LogDebug("ARM resource group check: {StatusCode}", response.StatusCode);
if (response.StatusCode == HttpStatusCode.OK) return true;
if (response.StatusCode == HttpStatusCode.NotFound) return false;
return null; // 401/403/5xx — caller falls back to az CLI
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ARM resource group check failed — will fall back to az CLI");
if (NetworkHelper.IsConnectionResetByProxy(ex))
_logger.LogWarning(NetworkHelper.ConnectionResetWarning);
else
_logger.LogDebug(ex, "ARM resource group check failed — will fall back to az CLI");
return null;
}
}
Expand All @@ -106,15 +114,19 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke

try
{
using var response = await _httpClient.GetAsync(url, ct);
using var response = await _retryHelper.ExecuteWithRetryAsync(
ct => _httpClient.GetAsync(url, ct), cancellationToken: ct);
_logger.LogDebug("ARM app service plan check: {StatusCode}", response.StatusCode);
if (response.StatusCode == HttpStatusCode.OK) return true;
if (response.StatusCode == HttpStatusCode.NotFound) return false;
return null; // 401/403/5xx — caller falls back to az CLI
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ARM app service plan check failed — will fall back to az CLI");
if (NetworkHelper.IsConnectionResetByProxy(ex))
_logger.LogWarning(NetworkHelper.ConnectionResetWarning);
else
_logger.LogDebug(ex, "ARM app service plan check failed — will fall back to az CLI");
return null;
}
}
Expand All @@ -138,15 +150,19 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke

try
{
using var response = await _httpClient.GetAsync(url, ct);
using var response = await _retryHelper.ExecuteWithRetryAsync(
ct => _httpClient.GetAsync(url, ct), cancellationToken: ct);
_logger.LogDebug("ARM web app check: {StatusCode}", response.StatusCode);
if (response.StatusCode == HttpStatusCode.OK) return true;
if (response.StatusCode == HttpStatusCode.NotFound) return false;
return null; // 401/403/5xx — caller falls back to az CLI
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ARM web app check failed — will fall back to az CLI");
if (NetworkHelper.IsConnectionResetByProxy(ex))
_logger.LogWarning(NetworkHelper.ConnectionResetWarning);
else
_logger.LogDebug(ex, "ARM web app check failed — will fall back to az CLI");
return null;
}
}
Expand Down Expand Up @@ -186,7 +202,8 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke

try
{
using var response = await _httpClient.GetAsync(url, ct);
using var response = await _retryHelper.ExecuteWithRetryAsync(
ct => _httpClient.GetAsync(url, ct), cancellationToken: ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug("ARM role assignment check returned {StatusCode}", response.StatusCode);
Expand Down Expand Up @@ -218,8 +235,12 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ARM role assignment check failed — will fall back to az CLI");
if (NetworkHelper.IsConnectionResetByProxy(ex))
_logger.LogWarning(NetworkHelper.ConnectionResetWarning);
else
_logger.LogDebug(ex, "ARM role assignment check failed — will fall back to az CLI");
return null;
}
}

}
Loading
Loading