Files
wehub-resource-sync db620d33df
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:25 +08:00

313 lines
13 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
// Hosted Toolbox Auth Paths — OAuth consent REPL client.
//
// This REPL drives the Hosted-Toolbox-AuthPaths agent and, unlike the plain
// Using-Samples/SimpleAgent client, it understands the OAuth user-consent path.
//
// When a toolbox tool source is fronted by a per-user OAuth connection (for example a
// delegated Microsoft Graph or a Logic Apps connector), the Foundry toolbox proxy cannot
// call the tool until the end user has consented. The hosted agent surfaces that as an
// oauth_consent_request output item carrying a consent link, and marks the response
// incomplete. This is the platform-canonical consent surface (the same item the Foundry
// Bot/Teams and A2A heads render as "open link + resume"), distinct from mcp_approval_request,
// which is the generic approve/deny tool gate. This client:
// 1. Detects the oauth_consent_request and extracts the consent link.
// 2. PRINTS the consent link and waits for the user to press Enter once they have completed
// the OAuth flow out of band. It never auto-opens a browser, so it works in headless,
// SSH, container, and other non-GUI environments.
// 3. RE-SENDS the original prompt on the same session. The proxy now holds the user's
// delegated token, so the retried tool call succeeds.
//
// Important: an OAuth consent request is resumed by RE-SENDING the prompt, NOT by replying
// with a ToolApprovalResponseContent. The consent request records no approval-id mapping on
// the server, so a CreateResponse(...) reply would be rejected. Only function-tool approvals
// (which this client also handles, for completeness) use the CreateResponse path.
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT - Foundry project endpoint, or the local dev server base
// (e.g. http://localhost:8088/api/projects/local).
// AZURE_AI_AGENT_NAME - The registered server-side agent name
// (default: hosted-toolbox-auth-paths-agent).
using System.ClientModel.Primitives;
using System.Text.Json;
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Extensions.AI;
// Load .env file if present (for local development)
Env.TraversePath().Load();
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME")
?? "hosted-toolbox-auth-paths-agent";
// Derive the per-agent OpenAI endpoint that hosted Foundry agents require.
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
var options = new AIProjectClientOptions();
if (projectEndpoint.Scheme == "http")
{
// For local HTTP dev: present HTTPS to satisfy BearerTokenPolicy's TLS check, then swap
// the scheme back to HTTP right before the request hits the wire.
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri;
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
}
var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint);
AgentSession session = await agent.CreateSessionAsync();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"""
══════════════════════════════════════════════════════════
Hosted Toolbox Auth Paths — OAuth consent client
Connected to: {agentEndpoint}
Ask something that needs the OAuth-protected tool to trigger consent.
Type a message or 'quit' to exit.
══════════════════════════════════════════════════════════
""");
Console.ResetColor();
Console.WriteLine();
while (true)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("You> ");
Console.ResetColor();
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input)) { continue; }
if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; }
try
{
AgentResponse response = await agent.RunAsync(input, session);
// Resolve any consent or approval requests before showing the final answer. Each
// round-trip either re-sends (OAuth consent) or replies with an approval decision
// (function tools).
while (true)
{
List<AIContent> contents = response.Messages
.SelectMany(m => m.Contents)
.ToList();
// An OAuth consent request surfaces as an oauth_consent_request output item carrying a
// consent link. It is resumed by RE-SENDING the prompt (no reply item), because the
// proxy stores the user's delegated token server-side keyed to the user.
List<string> consentUrls = contents
.Select(TryExtractConsentUrl)
.Where(url => url is not null)
.Select(url => url!)
.Distinct(StringComparer.Ordinal)
.ToList();
if (consentUrls.Count > 0)
{
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine();
Console.WriteLine("The agent needs your OAuth consent before it can call a tool on your behalf.");
Console.WriteLine("Open the link(s) below in any browser and complete the sign-in / consent:");
foreach (string url in consentUrls)
{
Console.WriteLine();
Console.WriteLine(url);
}
Console.WriteLine();
Console.ResetColor();
// Wait for the user to finish consent out of band. They can press Enter once done.
// The value is not needed to resume — the proxy stores the delegated token
// server-side keyed to the user.
Console.Write("After completing consent, press Enter to continue... ");
_ = Console.ReadLine();
// Re-send the SAME prompt on the SAME session. The proxy now holds the user's
// delegated token, so the retried tool call succeeds. Do NOT CreateResponse here.
response = await agent.RunAsync(input, session);
continue;
}
// Function-tool approval path (Y/N), included so the client also handles agents
// that mix human-in-the-loop function approvals with OAuth consent.
List<ToolApprovalRequestContent> approvals = contents
.OfType<ToolApprovalRequestContent>()
.ToList();
if (approvals.Count == 0)
{
break;
}
List<ChatMessage> decisions = approvals.ConvertAll(approval =>
{
string name = (approval.ToolCall as FunctionCallContent)?.Name ?? "tool";
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write($"Approve tool call '{name}'? [Y/N] ");
Console.ResetColor();
bool approved = Console.ReadLine()?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false;
return new ChatMessage(ChatRole.User, [approval.CreateResponse(approved)]);
});
response = await agent.RunAsync(decisions, session);
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Agent> {response}");
Console.ResetColor();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
}
Console.WriteLine();
}
Console.WriteLine("Goodbye!");
// ── Helpers ─────────────────────────────────────────────────────────────────────
// Extracts an OAuth consent link from a response content, or returns null when the content is
// not a consent request. The canonical surface is an `oauth_consent_request` output item, which
// the high-level client exposes through AIContent.RawRepresentation (its `ConsentLink` field
// carries the URL). For resilience this also handles a consent URL surfaced inside a
// ToolApprovalRequestContent payload (the older mcp_approval_request shape). Plain
// human-in-the-loop function approvals carry no URL and return null.
static string? TryExtractConsentUrl(AIContent content)
{
// 1) Canonical: oauth_consent_request output item via RawRepresentation.
if (TryGetConsentLinkFromRaw(content.RawRepresentation) is { } fromRaw)
{
return fromRaw;
}
// 2) Back-compat: a consent URL carried in an approval request's arguments.
if (content is ToolApprovalRequestContent approval)
{
return TryExtractConsentUrlFromApproval(approval);
}
return null;
}
// Reads a consent link from a raw oauth_consent_request item. The high-level client surfaces this
// item as a base AIContent whose RawRepresentation is an SDK response item: in the typed case it
// exposes a `ConsentLink` member, but the OpenAI Responses client parses the (non-OpenAI)
// oauth_consent_request as an *unknown* item, so the link only lives in the item's JSON. We try the
// typed member first, then fall back to serializing the persistable model and reading `consent_link`.
static string? TryGetConsentLinkFromRaw(object? raw)
{
if (raw is null)
{
return null;
}
// Fast path: a typed consent item exposes ConsentLink directly (Uri or string).
switch (raw.GetType().GetProperty("ConsentLink")?.GetValue(raw))
{
case Uri uri when LooksLikeUrl(uri.AbsoluteUri):
return uri.AbsoluteUri;
case string s when LooksLikeUrl(s):
return s;
}
// General path: serialize the unknown response item back to JSON and read the wire fields.
try
{
BinaryData json = ModelReaderWriter.Write(raw, new ModelReaderWriterOptions("J"));
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("type", out JsonElement typeProp)
&& typeProp.GetString() == "oauth_consent_request"
&& root.TryGetProperty("consent_link", out JsonElement linkProp)
&& linkProp.GetString() is string link
&& LooksLikeUrl(link))
{
return link;
}
}
catch
{
// Not a persistable model, or no consent_link present — fall through.
}
return null;
}
// Scans an approval request's tool-call arguments for a consent URL (older wire shape, where a
// toolbox OAuth consent was surfaced as an mcp_approval_request carrying a consent_url argument).
static string? TryExtractConsentUrlFromApproval(ToolApprovalRequestContent approval)
{
IDictionary<string, object?>? arguments = approval.ToolCall switch
{
McpServerToolCallContent mcpCall => mcpCall.Arguments,
FunctionCallContent functionCall => functionCall.Arguments,
_ => null,
};
// Only the explicit consent_url key counts (the legacy mcp_approval_request consent shape).
// We deliberately do NOT scan arbitrary argument values for URLs, so a normal function-tool
// approval that happens to carry a URL argument is never misread as an OAuth consent request.
if (arguments is null || !arguments.TryGetValue("consent_url", out object? value))
{
return null;
}
return value switch
{
string s when LooksLikeUrl(s) => s,
JsonElement { ValueKind: JsonValueKind.String } element
when element.GetString() is string elementString && LooksLikeUrl(elementString) => elementString,
_ => null,
};
}
static bool LooksLikeUrl(string value) =>
value.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| value.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// For Local Development Only.
/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient to target a
/// local HTTP dev server while satisfying BearerTokenPolicy's TLS check.
/// </summary>
internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
RewriteScheme(message);
ProcessNext(message, pipeline, currentIndex);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
RewriteScheme(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
private static void RewriteScheme(PipelineMessage message)
{
var uri = message.Request.Uri!;
if (uri.Scheme == Uri.UriSchemeHttps)
{
message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri;
}
}
}