diff --git a/dotnet/w365-computer-use/sample-agent/ComputerUse/AzureOpenAIModelProvider.cs b/dotnet/w365-computer-use/sample-agent/ComputerUse/AzureOpenAIModelProvider.cs index 96a2a7c5..5847a27f 100644 --- a/dotnet/w365-computer-use/sample-agent/ComputerUse/AzureOpenAIModelProvider.cs +++ b/dotnet/w365-computer-use/sample-agent/ComputerUse/AzureOpenAIModelProvider.cs @@ -22,27 +22,12 @@ public AzureOpenAIModelProvider(IHttpClientFactory httpClientFactory, IConfigura { _httpClient = httpClientFactory.CreateClient("WebClient"); _logger = logger; - var endpoint = configuration["AIServices:AzureOpenAI:Endpoint"] - ?? throw new InvalidOperationException("AIServices:AzureOpenAI:Endpoint is required."); _apiKey = configuration["AIServices:AzureOpenAI:ApiKey"] ?? throw new InvalidOperationException("AIServices:AzureOpenAI:ApiKey is required."); - var apiVersion = configuration["AIServices:AzureOpenAI:ApiVersion"] ?? "2025-04-01-preview"; - // DeploymentName = deployment-based URL; ModelName = model-based URL (model sent in body) - var deploymentName = configuration["AIServices:AzureOpenAI:DeploymentName"]; - ModelName = configuration["AIServices:AzureOpenAI:ModelName"] - ?? deploymentName - ?? "computer-use-preview"; - - if (!string.IsNullOrEmpty(deploymentName)) - { - _url = $"{endpoint.TrimEnd('/')}/openai/deployments/{deploymentName}/responses?api-version={apiVersion}"; - } - else - { - // Model-based endpoint — model name goes in the request body, not the URL - _url = $"{endpoint.TrimEnd('/')}/openai/responses?api-version={apiVersion}"; - } + var options = AzureOpenAIModelProviderOptions.FromConfiguration(configuration); + ModelName = options.ModelName; + _url = options.Url; } public async Task SendAsync(string requestBody, CancellationToken cancellationToken) diff --git a/dotnet/w365-computer-use/sample-agent/ComputerUse/AzureOpenAIModelProviderOptions.cs b/dotnet/w365-computer-use/sample-agent/ComputerUse/AzureOpenAIModelProviderOptions.cs new file mode 100644 index 00000000..04e9ed1f --- /dev/null +++ b/dotnet/w365-computer-use/sample-agent/ComputerUse/AzureOpenAIModelProviderOptions.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace W365ComputerUseSample.ComputerUse; + +internal sealed class AzureOpenAIModelProviderOptions +{ + public required string Url { get; init; } + + public required string ModelName { get; init; } + + public static AzureOpenAIModelProviderOptions FromConfiguration(IConfiguration configuration) + { + var endpoint = configuration["AIServices:AzureOpenAI:Endpoint"] + ?? throw new InvalidOperationException("AIServices:AzureOpenAI:Endpoint is required."); + + var configuredModelName = configuration["AIServices:AzureOpenAI:ModelName"]; + var deploymentName = configuration["AIServices:AzureOpenAI:DeploymentName"]; + var modelName = !string.IsNullOrWhiteSpace(configuredModelName) + ? configuredModelName + : !string.IsNullOrWhiteSpace(deploymentName) + ? deploymentName + : "computer-use-preview"; + + return new AzureOpenAIModelProviderOptions + { + ModelName = modelName, + Url = $"{endpoint.TrimEnd('/')}/openai/v1/responses", + }; + } +} diff --git a/dotnet/w365-computer-use/sample-agent/README.md b/dotnet/w365-computer-use/sample-agent/README.md index 57f9fad0..4f78f143 100644 --- a/dotnet/w365-computer-use/sample-agent/README.md +++ b/dotnet/w365-computer-use/sample-agent/README.md @@ -44,7 +44,7 @@ Response to User - `computer-use-preview` or `gpt-5.4` / `gpt-5.4-mini` - [Request access to gpt-5.4](https://aka.ms/OAI/gpt54access) if needed - Access to the W365 Computer Use MCP server (via [Agent 365 MCP Platform](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/)) -- A bearer token with `McpServers.W365ComputerUse.All` scope +- A bearer token with `Tools.ListInvoke.All` scope ## Setup @@ -82,6 +82,9 @@ Create `appsettings.Development.json` (this file is gitignored): } ``` +`DeploymentName` is treated as the model identifier fallback for compatibility with existing local settings. Azure OpenAI requests are sent to the v1 Responses endpoint (`/openai/v1/responses`), not the legacy deployment-style Responses URL. The selected `ModelName` or fallback `DeploymentName` is sent as the request body `model`. + + **For `gpt-5.4-mini` model:** ```json { @@ -101,7 +104,48 @@ Create `appsettings.Development.json` (this file is gitignored): ### 4. Obtain a bearer token -Get a token with the `McpServers.W365ComputerUse.All` scope for your tenant. See the [Agent 365 MCP Platform docs](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) for details. +> **Note:** Running locally requires an agent identity. Create an Agent Blueprint with an Agent Identity for local development, then use that identity's client ID and the Agent Blueprint client credentials in the commands below. + +#### Get the Windows 365 for Agents MCP token + +Use the helper script to get a CUA user token for the MCP server, then set it as `BEARER_TOKEN`: + +```powershell + $tenantId = "" + $blueprintClientId = "" + $blueprintClientSecret = Read-Host "Agent Blueprint client secret" -AsSecureString + $blueprintClientSecretPlainText = [System.Net.NetworkCredential]::new("", $blueprintClientSecret).Password + $agentClientId = "" + $agentUpn = "" + + .\scripts\Get-CuaAgentUserToken.ps1 ` + -TenantId $tenantId ` + -AgentBlueprintClientId $blueprintClientId ` + -AgentBlueprintClientSecret $blueprintClientSecretPlainText ` + -AgentClientId $agentClientId ` + -AgentUsername $agentUpn ` + -InformationAction Continue + ``` + + The script assigns the generated token to `$env:BEARER_TOKEN` for the current PowerShell process and writes an informational message. To use a different token audience, pass `-Scope ""`; by default the script requests `da81128c-e5b5-4f9e-8d89-50d906f107c5/.default`. + +The script requests scopes for the Windows 365 for Agents MCP server. For this sample, use the `Tools.ListInvoke.All` scope. + +#### Optional: Get a Microsoft Graph token for OneDrive screenshots + +This token is optional and is only needed when you want the sample to upload screenshots to OneDrive. + +```powershell +Install-Module MSAL.PS -Scope CurrentUser + +$token = Get-MsalToken ` + -ClientId "" ` + -TenantId "organizations" ` + -Scopes "https://graph.microsoft.com/Files.ReadWrite" ` + -Interactive + +$env:GRAPH_TOKEN = $token.AccessToken +``` ### 5. Start the MCP Platform server @@ -112,7 +156,6 @@ Ensure the MCP Platform is running locally on port 52857, or update the `McpServ ```powershell cd sample-agent $env:ASPNETCORE_ENVIRONMENT = "Development" -$env:BEARER_TOKEN = "" $env:GRAPH_TOKEN = "" dotnet run ``` @@ -131,7 +174,7 @@ dotnet run | `AIServices:Provider` | Model provider | `AzureOpenAI` | | `AIServices:AzureOpenAI:Endpoint` | Azure OpenAI resource URL | - | | `AIServices:AzureOpenAI:ApiKey` | API key | - | -| `AIServices:AzureOpenAI:DeploymentName` | Deployment name (for deployment-based URLs) | `computer-use-preview` | +| `AIServices:AzureOpenAI:DeploymentName` | Backward-compatible model identifier fallback when `ModelName` is not set | `computer-use-preview` | | `AIServices:AzureOpenAI:ModelName` | Model name (for model-based URLs, e.g., `gpt-5.4-mini`) | - | | `McpServer:Url` | MCP server URL (dev only; omit for production) | - | | `W365:GatewayUrl` | W365 Computer Use MCP gateway URL (production) | `https://agent365.svc.cloud.microsoft/agents/servers/mcp_W365ComputerUse` | @@ -141,7 +184,7 @@ dotnet run | `Screenshots:LocalPath` | Local path to save screenshots | `./Screenshots` | | `Screenshots:OneDriveFolder` | OneDrive folder for screenshot upload | `CUA-Sessions` | | `Screenshots:OneDriveUserId` | UPN/email to upload screenshots to a specific user's OneDrive (instead of token owner) | - | -| `BEARER_TOKEN` (env var) | MCP Platform token with `McpServers.W365ComputerUse.All` scope (dev only) | - | +| `BEARER_TOKEN` (env var) | MCP Platform token with `Tools.ListInvoke.All` scope (dev only) | - | | `GRAPH_TOKEN` (env var) | Graph API token with `Files.ReadWrite` scope for OneDrive upload (dev only) | - | ## Supported Models diff --git a/dotnet/w365-computer-use/sample-agent/appsettings.json b/dotnet/w365-computer-use/sample-agent/appsettings.json index 25f2f3d9..c3ac3a0a 100644 --- a/dotnet/w365-computer-use/sample-agent/appsettings.json +++ b/dotnet/w365-computer-use/sample-agent/appsettings.json @@ -71,8 +71,7 @@ "DeploymentName": "<>", "ModelName": "", "Endpoint": "<>", - "ApiKey": "<>", - "ApiVersion": "2025-04-01-preview" + "ApiKey": "<>" } }, diff --git a/dotnet/w365-computer-use/sample-agent/scripts/Get-CuaAgentUserToken.ps1 b/dotnet/w365-computer-use/sample-agent/scripts/Get-CuaAgentUserToken.ps1 new file mode 100644 index 00000000..b67f01de --- /dev/null +++ b/dotnet/w365-computer-use/sample-agent/scripts/Get-CuaAgentUserToken.ps1 @@ -0,0 +1,192 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS +Acquires a CUA user access token for an agent using the Entra user_fic flow. + +.DESCRIPTION +Requests an application token, exchanges it for an agent identity token, and then +requests a CUA user token from Microsoft Entra ID. By default, the script assigns +the final CUA access token to $env:BEARER_TOKEN in the current PowerShell +process. Use -ShowOid +to decode the final token payload and write the oid claim to the information +stream. + +.EXAMPLE +.\Get-CuaAgentUserToken.ps1 -TenantId "contoso.onmicrosoft.com" -AgentBlueprintClientId "00000000-0000-0000-0000-000000000000" -AgentBlueprintClientSecret "" -AgentClientId "11111111-1111-1111-1111-111111111111" -AgentUsername "user@contoso.com" + +Assigns the final CUA access token to $env:BEARER_TOKEN in the current +PowerShell process. + +.EXAMPLE +.\Get-CuaAgentUserToken.ps1 -TenantId "contoso.onmicrosoft.com" -AgentBlueprintClientId "00000000-0000-0000-0000-000000000000" -AgentBlueprintClientSecret "" -AgentClientId "11111111-1111-1111-1111-111111111111" -AgentUsername "user@contoso.com" -SetBearerToken -InformationAction Continue + +Assigns the final CUA access token to $env:BEARER_TOKEN in the current +PowerShell process. + +.EXAMPLE +.\Get-CuaAgentUserToken.ps1 -TenantId "contoso.onmicrosoft.com" -AgentBlueprintClientId "00000000-0000-0000-0000-000000000000" -AgentBlueprintClientSecret "" -AgentClientId "11111111-1111-1111-1111-111111111111" -AgentUsername "user@contoso.com" -ShowOid -InformationAction Continue + +Assigns the final CUA access token to $env:BEARER_TOKEN and writes the token +oid claim to the information stream. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$TenantId, + [Parameter(Mandatory = $true)] + [string]$AgentBlueprintClientId, + [Parameter(Mandatory = $true)] + [string]$AgentBlueprintClientSecret, + [Parameter(Mandatory = $true)] + [string]$AgentClientId, + [Parameter(Mandatory = $true)] + [string]$AgentUsername, + [string]$AuthorityHost = "https://login.microsoftonline.com", + [string]$Scope = "da81128c-e5b5-4f9e-8d89-50d906f107c5/.default", + [switch]$SetBearerToken = $true, + [switch]$ShowOid +) + +$ErrorActionPreference = "Stop" + +function Assert-RequiredParameter { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [AllowNull()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "Parameter validation failed: -$Name is required." + } +} + +function Get-AccessTokenFromResponse { + param( + [Parameter(Mandatory = $true)] + [object]$Response, + + [Parameter(Mandatory = $true)] + [string]$StepLabel + ) + + if ($null -eq $Response -or [string]::IsNullOrWhiteSpace($Response.access_token)) { + throw "$StepLabel failed: token response missing required field 'access_token'." + } + + return $Response.access_token +} + +function ConvertFrom-Base64Url { + param( + [Parameter(Mandatory = $true)] + [string]$Value + ) + + $base64 = $Value.Replace("-", "+").Replace("_", "/") + $padding = (4 - ($base64.Length % 4)) % 4 + if ($padding -gt 0) { + $base64 = $base64 + ("=" * $padding) + } + + $bytes = [Convert]::FromBase64String($base64) + return [System.Text.Encoding]::UTF8.GetString($bytes) +} + +function Write-OidInformation { + param( + [Parameter(Mandatory = $true)] + [string]$AccessToken + ) + + try { + $segments = $AccessToken.Split(".") + if ($segments.Count -ne 3) { + Write-Warning "ShowOid decode failed: access token is not a valid JWT (expected 3 segments)." + return + } + + $payloadJson = ConvertFrom-Base64Url -Value $segments[1] + $claims = $payloadJson | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace($claims.oid)) { + Write-Warning "ShowOid decode completed but JWT payload did not contain an 'oid' claim." + return + } + + Write-Information $claims.oid + } + catch { + Write-Warning "ShowOid decode failed: $($_.Exception.Message)" + } +} + +Assert-RequiredParameter -Name "TenantId" -Value $TenantId +Assert-RequiredParameter -Name "AgentBlueprintClientId" -Value $AgentBlueprintClientId +Assert-RequiredParameter -Name "AgentBlueprintClientSecret" -Value $AgentBlueprintClientSecret +Assert-RequiredParameter -Name "AgentClientId" -Value $AgentClientId +Assert-RequiredParameter -Name "AgentUsername" -Value $AgentUsername +Assert-RequiredParameter -Name "AuthorityHost" -Value $AuthorityHost +Assert-RequiredParameter -Name "Scope" -Value $Scope + +$tokenUrl = "$($AuthorityHost.TrimEnd('/'))/$TenantId/oauth2/v2.0/token" +$clientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + +try { + $applicationTokenResponse = Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType "application/x-www-form-urlencoded" -Body @{ + client_id = $AgentBlueprintClientId + scope = "api://AzureADTokenExchange/.default" + grant_type = "client_credentials" + client_secret = $AgentBlueprintClientSecret + fmi_path = $AgentClientId + } + $applicationToken = Get-AccessTokenFromResponse -Response $applicationTokenResponse -StepLabel "Application token request" +} +catch { + throw "Application token request failed: $($_.Exception.Message)" +} + +try { + $agentIdentityTokenResponse = Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType "application/x-www-form-urlencoded" -Body @{ + client_id = $AgentClientId + scope = "api://AzureADTokenExchange/.default" + grant_type = "client_credentials" + client_assertion_type = $clientAssertionType + client_assertion = $applicationToken + } + $agentIdentityToken = Get-AccessTokenFromResponse -Response $agentIdentityTokenResponse -StepLabel "Agent identity token request" +} +catch { + throw "Agent identity token request failed: $($_.Exception.Message)" +} + +try { + $cuaTokenResponse = Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType "application/x-www-form-urlencoded" -Body @{ + client_id = $AgentClientId + scope = $Scope + grant_type = "user_fic" + client_assertion_type = $clientAssertionType + client_assertion = $applicationToken + username = $AgentUsername + user_federated_identity_credential = $agentIdentityToken + } + $cuaAccessToken = Get-AccessTokenFromResponse -Response $cuaTokenResponse -StepLabel "CUA token request" +} +catch { + throw "CUA token request failed: $($_.Exception.Message)" +} + +if ($ShowOid) { + Write-OidInformation -AccessToken $cuaAccessToken +} + +if ($SetBearerToken) { + $env:BEARER_TOKEN = $cuaAccessToken + Write-Information "Set `$env:BEARER_TOKEN for the current PowerShell process." +} +else { + Write-Information "Token acquired. `$env:BEARER_TOKEN was not set because -SetBearerToken was false." +}