diff --git a/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs index 67685ebeb..378fe4654 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelNyxIdConnectedServiceInventoryToolSource.cs @@ -780,7 +780,8 @@ private async Task ExecuteOperationWithContextAsync( arguments.ServiceSlug, arguments.OperationId, arguments.OperationArgumentsJson, - arguments.DocumentRequest), + arguments.DocumentRequest, + arguments.RawRequest), callId, "nyxid_invoke_operation", ct) @@ -1029,8 +1030,11 @@ literalVersion is null || manifestDigest is null || foreach (var property in root.EnumerateObject()) { - if (property.Name is not ("user_service_id" or "service_slug" or "operation_id" or "operation_arguments" or "document_request")) + if (property.Name is not ("user_service_id" or "service_slug" or "operation_id" or "operation_arguments" or + "document_request" or "method" or "relative_path" or "query" or "headers" or "body")) + { return null; + } } var userServiceId = ReadOptionalString(root, "user_service_id"); @@ -1040,9 +1044,14 @@ literalVersion is null || manifestDigest is null || var hasTypedOperation = root.TryGetProperty("operation_id", out _); var hasDocumentRequest = root.TryGetProperty("document_request", out var documentRequestElement); - if (hasTypedOperation == hasDocumentRequest) + var hasRawRequest = root.TryGetProperty("method", out _) || + root.TryGetProperty("relative_path", out _) || + root.TryGetProperty("query", out _) || + root.TryGetProperty("headers", out _) || + root.TryGetProperty("body", out _); + if ((hasTypedOperation ? 1 : 0) + (hasDocumentRequest ? 1 : 0) + (hasRawRequest ? 1 : 0) != 1) return null; - if (hasDocumentRequest && root.TryGetProperty("operation_arguments", out _)) + if ((hasDocumentRequest || hasRawRequest) && root.TryGetProperty("operation_arguments", out _)) return null; if (hasDocumentRequest) @@ -1055,7 +1064,22 @@ literalVersion is null || manifestDigest is null || serviceSlug, OperationId: null, OperationArgumentsJson: "{}", - documentRequest); + documentRequest, + RawRequest: null); + } + + if (hasRawRequest) + { + var rawRequest = ParseRawRequest(root); + return rawRequest is null + ? null + : new OperationArguments( + userServiceId, + serviceSlug, + OperationId: null, + OperationArgumentsJson: "{}", + DocumentRequest: null, + rawRequest); } var operationId = ReadRequiredString(root, "operation_id"); @@ -1075,7 +1099,8 @@ literalVersion is null || manifestDigest is null || serviceSlug, operationId, operationArgumentsJson, - DocumentRequest: null); + DocumentRequest: null, + RawRequest: null); } catch (JsonException) { @@ -1083,6 +1108,11 @@ literalVersion is null || manifestDigest is null || } } + private static NyxIdConnectedServiceRawRequest? ParseRawRequest(JsonElement root) => + TryReadAuthoredRequest(root, out var method, out var relativePath, out var requestArgumentsJson) + ? new NyxIdConnectedServiceRawRequest(method, relativePath, requestArgumentsJson) + : null; + private static NyxIdConnectedServiceDocumentRequest? ParseDocumentRequest(JsonElement documentRequest) { if (documentRequest.ValueKind != JsonValueKind.Object) @@ -1093,40 +1123,58 @@ literalVersion is null || manifestDigest is null || return null; } - var method = ReadRequiredString(documentRequest, "method"); - var relativePath = ReadRequiredString(documentRequest, "relative_path"); - if (method is null || relativePath is null || - !documentRequest.TryGetProperty("skill_ref", out var skillRefElement) || - !TryReadDocumentSkillRef(skillRefElement, out var skillRef)) + if (!documentRequest.TryGetProperty("skill_ref", out var skillRefElement) || + !TryReadDocumentSkillRef(skillRefElement, out var skillRef) || + !TryReadAuthoredRequest( + documentRequest, + out var method, + out var relativePath, + out var requestArgumentsJson)) { return null; } + return new NyxIdConnectedServiceDocumentRequest( + method, + relativePath, + requestArgumentsJson, + skillRef); + } + + private static bool TryReadAuthoredRequest( + JsonElement root, + out string method, + out string relativePath, + out string requestArgumentsJson) + { + method = ReadRequiredString(root, "method") ?? string.Empty; + relativePath = ReadRequiredString(root, "relative_path") ?? string.Empty; + requestArgumentsJson = string.Empty; + if (method.Length == 0 || relativePath.Length == 0) + return false; + var runtimeArguments = new Dictionary(StringComparer.Ordinal); - if (documentRequest.TryGetProperty("query", out var query)) + if (root.TryGetProperty("query", out var query)) { if (query.ValueKind != JsonValueKind.Object) - return null; + return false; runtimeArguments["query"] = query; } - if (documentRequest.TryGetProperty("headers", out var headers)) + if (root.TryGetProperty("headers", out var headers)) { if (headers.ValueKind != JsonValueKind.Object) - return null; + return false; runtimeArguments["headers"] = headers; } - if (documentRequest.TryGetProperty("body", out var body)) + if (root.TryGetProperty("body", out var body)) { if (body.ValueKind is not (JsonValueKind.Object or JsonValueKind.Null)) - return null; + return false; runtimeArguments["body"] = body; } - return new NyxIdConnectedServiceDocumentRequest( - method, - relativePath, - JsonSerializer.Serialize(runtimeArguments), - skillRef); + requestArgumentsJson = JsonSerializer.Serialize(runtimeArguments); + return true; } private static bool TryReadDocumentSkillRef(JsonElement root, out NyxIdRecommendedSkillRef skillRef) @@ -1400,7 +1448,8 @@ private sealed record OperationArguments( string? ServiceSlug, string? OperationId, string OperationArgumentsJson, - NyxIdConnectedServiceDocumentRequest? DocumentRequest); + NyxIdConnectedServiceDocumentRequest? DocumentRequest, + NyxIdConnectedServiceRawRequest? RawRequest); private sealed record RecommendedSkillArguments( string UserServiceId, @@ -1423,6 +1472,11 @@ private sealed class SenderConnectedServiceOperationTool(ChannelNyxIdConnectedSe "service_slug":{"type":"string","description":"Exact connected-service slug from inventory or channel runtime selectors."}, "operation_id":{"type":"string","description":"Exact endpoint or operation id named by the loaded recommended skill for typed operation mode."}, "operation_arguments":{"type":"object","description":"Typed mode only. Only path_params, query, headers, body, and response_mode values declared by the operation contract.","additionalProperties":true}, + "method":{"type":"string","description":"Raw delegated mode only. HTTP method for a service-relative request when operation_id is omitted.","enum":["GET","HEAD","OPTIONS","POST","PUT","PATCH","DELETE"]}, + "relative_path":{"type":"string","description":"Raw delegated mode only. Safe relative service path. Absolute URLs, query strings, fragments, and traversal are rejected."}, + "query":{"type":"object","description":"Raw delegated mode only. Query string values for the service-relative request.","additionalProperties":{"type":"string"}}, + "headers":{"type":"object","description":"Raw delegated mode only. Non-sensitive headers. Authorization, Host, cookies, API keys, and tokens are rejected.","additionalProperties":{"type":"string"}}, + "body":{"type":"object","description":"Raw delegated mode only. JSON object body for methods that allow a body.","additionalProperties":true}, "document_request":{ "type":"object", "description":"Document-guided mode only. Use only when the loaded recommended skill explicitly describes a service without typed operations. This is not a fallback for a missing operation_id.", @@ -1450,14 +1504,15 @@ private sealed class SenderConnectedServiceOperationTool(ChannelNyxIdConnectedSe } }, "anyOf":[{"required":["user_service_id"]},{"required":["service_slug"]}], - "oneOf":[{"required":["operation_id"]},{"required":["document_request"]}], + "oneOf":[{"required":["operation_id"]},{"required":["document_request"]},{"required":["method","relative_path"]}], "additionalProperties":false } """; public string Name => "nyxid_invoke_operation"; public string Description => - "Invoke one current NyxID connected-service request selected by exact service identity and either a typed operation id or an explicit document-guided request."; + "Invoke one current NyxID connected-service request selected by exact service identity. " + + "Use a typed operation id or document guidance when available; otherwise provide a service-relative delegated request."; public string ParametersSchema => Schema; public bool IsReadOnly => false; public bool IsDestructive => false; diff --git a/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdConnectedServiceOperationInvoker.cs b/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdConnectedServiceOperationInvoker.cs index 4102f6497..6dbce55d8 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdConnectedServiceOperationInvoker.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdConnectedServiceOperationInvoker.cs @@ -1,7 +1,6 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.AI.ToolProviders.NyxId.Tools; @@ -16,7 +15,8 @@ public sealed record NyxIdConnectedServiceOperationInvocation( string? ServiceSlug, string? OperationId, string OperationArgumentsJson, - NyxIdConnectedServiceDocumentRequest? DocumentRequest = null); + NyxIdConnectedServiceDocumentRequest? DocumentRequest = null, + NyxIdConnectedServiceRawRequest? RawRequest = null); public sealed record NyxIdConnectedServiceDocumentRequest( string Method, @@ -24,6 +24,11 @@ public sealed record NyxIdConnectedServiceDocumentRequest( string RequestArgumentsJson, NyxIdRecommendedSkillRef SkillRef); +public sealed record NyxIdConnectedServiceRawRequest( + string Method, + string RelativePath, + string RequestArgumentsJson); + public sealed record NyxIdConnectedServiceOperationInvokeResult( bool IsSuccess, AgentToolTerminalOutcome? Outcome, @@ -38,21 +43,9 @@ public static NyxIdConnectedServiceOperationInvokeResult Failure(string failureC public sealed class NyxIdConnectedServiceOperationInvoker { - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = false, - }; - private static readonly TimeSpan CatalogFreshnessWindow = TimeSpan.FromMinutes(5); private const int CustomOpenApiMaxBytes = 1024 * 1024; private const int MaxReadSourceBytes = 16 * 1024; - private const string ProxyResponseTooLargeErrorCode = "NYXID_PROXY_RESPONSE_TOO_LARGE"; - private const string ReadTooLargeErrorCode = "NYXID_CONNECTED_SERVICE_READ_TOO_LARGE"; - private const string ReadTooLargeErrorMessage = - "The connected-service read result exceeded the bounded projection limit. " + - "Retry with a narrower query or smaller page size and paginate across bounded reads."; - private const string ReadProjectionKind = "connected_service_read_projection"; - private const string EffectReceiptKind = "connected_service_effect_receipt"; private readonly NyxIdToolOptions _options; private readonly NyxIdApiClient _apiClient; @@ -111,7 +104,6 @@ public async Task InvokeAsync( return NyxIdConnectedServiceOperationInvokeResult.Failure("operation_ambiguous"); return await InvokeDocumentGuidedRequestAsync( context, - executionToken, matchedBindings[0], invocation.DocumentRequest, callId, @@ -120,6 +112,20 @@ public async Task InvokeAsync( .ConfigureAwait(false); } + if (invocation.RawRequest is not null) + { + if (matchedBindings.Length > 1) + return NyxIdConnectedServiceOperationInvokeResult.Failure("operation_ambiguous"); + return await InvokeRawDelegatedRequestAsync( + context, + matchedBindings[0], + invocation.RawRequest, + callId, + toolName, + ct) + .ConfigureAwait(false); + } + var services = await ReadOperationContractsAsync( context, executionToken, @@ -170,66 +176,18 @@ public async Task InvokeAsync( match.Endpoint, match.Service.Source.ContentDigest, match.Binding.Instance); - if (readBackPlan?.TryFreeze(invocation.OperationArgumentsJson, out var readBack) == true) - admission = admission with { ReadBack = readBack }; - - var proxy = new NyxIdProxyTool( - _apiClient, - _logger, - _fileArtifactIngress, - _options.EffectiveProxyFileArtifactMaxBytes, - _options.ManagedWorkflowAdmissionMode, - _delegationTokenLease); - var sourceReadableToken = AgentToolSourceReadableNyxIdCredential.ResolveBearerToken(context.Credentials) - ?? context.Credentials.NyxIdAccessToken; - var operationToken = match.Binding.Instance.AccessTokenSource == NyxIdServiceAccessTokenSource.Organization - ? context.Credentials.NyxIdOrgToken - : context.Credentials.NyxIdAccessToken; - var credentials = context.Credentials with - { - NyxIdAccessToken = operationToken, - SourceReadableNyxIdAccessToken = sourceReadableToken, - }; - using var scope = AgentToolContextScope.Push(context with - { - Credentials = credentials, - OperationAdmission = admission, - }); - var outcome = admission.ExecutionPolicy.Risk == AgentToolOperationRisk.ReadOnly - ? await proxy.ExecuteAdmittedReadWithOutcomeAsync( - callId, - toolName, - invocation.OperationArgumentsJson, - MaxReadSourceBytes, - ct).ConfigureAwait(false) - : await proxy.ExecuteAdmittedEffectWithOutcomeAsync( - callId, - toolName, - invocation.OperationArgumentsJson, - ct).ConfigureAwait(false); - var receipt = outcome.Receipt ?? proxy.CreateResultReceipt( - callId, - toolName, - invocation.OperationArgumentsJson, - outcome.ResultJson); - var terminalOutcome = admission.ExecutionPolicy.Risk == AgentToolOperationRisk.ReadOnly - ? BuildReadOutcome( + return await ExecuteThroughOperationToolAsync( + context, + match.Binding, admission, + invocation.OperationArgumentsJson, match.Service.ServiceName, match.Endpoint.Name, callId, toolName, - outcome.ResultJson, - receipt) - : BuildEffectOutcome( - admission, readBackPlan, - match.Service.ServiceName, - match.Endpoint.Name, - callId, - toolName, - receipt); - return NyxIdConnectedServiceOperationInvokeResult.Success(terminalOutcome); + ct) + .ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -244,7 +202,6 @@ public async Task InvokeAsync( private async Task InvokeDocumentGuidedRequestAsync( AgentToolExecutionContext context, - string executionToken, NyxIdServiceInstanceBinding binding, NyxIdConnectedServiceDocumentRequest request, string callId, @@ -253,8 +210,87 @@ private async Task InvokeDocumentGui { if (!HasExactRecommendedSkillRef(binding.Instance, request.SkillRef)) return NyxIdConnectedServiceOperationInvokeResult.Failure("document_request_not_admitted"); - if (!TryBuildDocumentGuidedAdmission(binding.Instance, request, out var admission, out var runtimeArgumentsJson)) - return NyxIdConnectedServiceOperationInvokeResult.Failure("document_request_invalid"); + return await InvokeAuthoredRequestAsync( + context, + binding, + request.Method, + request.RelativePath, + request.RequestArgumentsJson, + invalidFailureCode: "document_request_invalid", + callId, + toolName, + ct) + .ConfigureAwait(false); + } + + private async Task InvokeRawDelegatedRequestAsync( + AgentToolExecutionContext context, + NyxIdServiceInstanceBinding binding, + NyxIdConnectedServiceRawRequest request, + string callId, + string toolName, + CancellationToken ct) + { + return await InvokeAuthoredRequestAsync( + context, + binding, + request.Method, + request.RelativePath, + request.RequestArgumentsJson, + invalidFailureCode: "raw_request_invalid", + callId, + toolName, + ct) + .ConfigureAwait(false); + } + + private async Task InvokeAuthoredRequestAsync( + AgentToolExecutionContext context, + NyxIdServiceInstanceBinding binding, + string method, + string relativePath, + string requestArgumentsJson, + string invalidFailureCode, + string callId, + string toolName, + CancellationToken ct) + { + if (!TryBuildAuthoredRequestAdmission( + binding.Instance, + method, + relativePath, + requestArgumentsJson, + out var admission)) + { + return NyxIdConnectedServiceOperationInvokeResult.Failure(invalidFailureCode); + } + + return await ExecuteThroughOperationToolAsync( + context, + binding, + admission, + requestArgumentsJson, + FirstNonEmpty(binding.Instance.Label, binding.Instance.DisplaySlug, binding.Instance.CatalogServiceSlug), + $"{admission.HttpMethod} {admission.PathTemplate}", + callId, + toolName, + readBackPlan: null, + ct) + .ConfigureAwait(false); + } + + private async Task ExecuteThroughOperationToolAsync( + AgentToolExecutionContext context, + NyxIdServiceInstanceBinding binding, + AgentToolOperationAdmission admission, + string runtimeArgumentsJson, + string serviceLabel, + string operationLabel, + string callId, + string toolName, + NyxIdConnectedServiceReadBackPlan? readBackPlan, + CancellationToken ct) + { if (admission.ExecutionPolicy.Risk != AgentToolOperationRisk.ReadOnly && !_options.EnableAssistantConnectedServiceEffects) { @@ -268,58 +304,25 @@ private async Task InvokeDocumentGui _options.EffectiveProxyFileArtifactMaxBytes, _options.ManagedWorkflowAdmissionMode, _delegationTokenLease); - var sourceReadableToken = AgentToolSourceReadableNyxIdCredential.ResolveBearerToken(context.Credentials) - ?? context.Credentials.NyxIdAccessToken; - var operationToken = binding.Instance.AccessTokenSource == NyxIdServiceAccessTokenSource.Organization - ? context.Credentials.NyxIdOrgToken - : executionToken; - var credentials = context.Credentials with - { - NyxIdAccessToken = operationToken, - SourceReadableNyxIdAccessToken = sourceReadableToken, - }; - using var scope = AgentToolContextScope.Push(context with - { - Credentials = credentials, - OperationAdmission = admission, - }); - var outcome = admission.ExecutionPolicy.Risk == AgentToolOperationRisk.ReadOnly - ? await proxy.ExecuteAdmittedReadWithOutcomeAsync( - callId, - toolName, - runtimeArgumentsJson, - MaxReadSourceBytes, - ct).ConfigureAwait(false) - : await proxy.ExecuteAdmittedEffectWithOutcomeAsync( + using var scope = AgentToolContextScope.Push(context); + var operationTool = new NyxIdConnectedServiceOperationTool( + proxy, + admission, + serviceLabel, + operationLabel, + FirstNonEmpty(binding.Instance.Label, binding.Instance.DisplaySlug, binding.Instance.CatalogServiceSlug), + readinessCapabilityId: null, + accessTokenSource: binding.Instance.AccessTokenSource, + readBackPlan: readBackPlan, + maxReadSourceBytes: MaxReadSourceBytes, + maxReadProjectionBytes: MaxReadSourceBytes); + var outcome = await operationTool.ExecuteWithOutcomeAsync( callId, toolName, runtimeArgumentsJson, - ct).ConfigureAwait(false); - var receipt = outcome.Receipt ?? proxy.CreateResultReceipt( - callId, - toolName, - runtimeArgumentsJson, - outcome.ResultJson); - var label = FirstNonEmpty(binding.Instance.Label, binding.Instance.DisplaySlug, binding.Instance.CatalogServiceSlug); - var operationLabel = $"{admission.HttpMethod} {admission.PathTemplate}"; - var terminalOutcome = admission.ExecutionPolicy.Risk == AgentToolOperationRisk.ReadOnly - ? BuildReadOutcome( - admission, - label, - operationLabel, - callId, - toolName, - outcome.ResultJson, - receipt) - : BuildEffectOutcome( - admission, - readBackPlan: null, - label, - operationLabel, - callId, - toolName, - receipt); - return NyxIdConnectedServiceOperationInvokeResult.Success(terminalOutcome); + ct) + .ConfigureAwait(false); + return NyxIdConnectedServiceOperationInvokeResult.Success(outcome); } private static bool HasExactRecommendedSkillRef( @@ -331,20 +334,20 @@ private static bool HasExactRecommendedSkillRef( string.Equals(skillRef.LiteralVersion, requestedRef.LiteralVersion, StringComparison.Ordinal) && string.Equals(skillRef.ManifestDigest, requestedRef.ManifestDigest, StringComparison.Ordinal)); - private static bool TryBuildDocumentGuidedAdmission( + private static bool TryBuildAuthoredRequestAdmission( NyxIdServiceInstance instance, - NyxIdConnectedServiceDocumentRequest request, - out AgentToolOperationAdmission admission, - out string runtimeArgumentsJson) + string requestMethod, + string relativePath, + string requestArgumentsJson, + out AgentToolOperationAdmission admission) { admission = null!; - runtimeArgumentsJson = string.Empty; - var method = NormalizeDocumentRequestMethod(request.Method); - if (method is null || !TryNormalizeDocumentRequestPath(request.RelativePath, out var pathTemplate)) + var method = NormalizeDocumentRequestMethod(requestMethod); + if (method is null || !TryNormalizeDocumentRequestPath(relativePath, out var pathTemplate)) return false; if (!TryReadDocumentRuntimeArguments( - request.RequestArgumentsJson, + requestArgumentsJson, out var queryParameters, out var headerParameters, out var hasBody)) @@ -405,14 +408,11 @@ private static bool TryBuildDocumentGuidedAdmission( AgentToolOperationResponsePolicy.TextOnly, new AgentToolOperationExecutionPolicy( risk, - risk == AgentToolOperationRisk.ReadOnly - ? AgentToolOperationApproval.None - : AgentToolOperationApproval.Required, + AgentToolOperationApproval.None, AgentToolOperationEnforcementOwner.Aevatar, [AgentToolOperationExecutionMode.Interactive]), CatalogDigest: string.Empty, CatalogServiceSlug: instance.CatalogServiceSlug); - runtimeArgumentsJson = request.RequestArgumentsJson; return true; } @@ -455,6 +455,8 @@ private static bool TryReadDocumentRuntimeArguments( .Where(static name => !string.IsNullOrWhiteSpace(name)) .Order(StringComparer.OrdinalIgnoreCase) .ToArray(); + if (headerParameters.Any(NyxIdProxyHeaderPolicy.IsSensitive)) + return false; } hasBody = root.TryGetProperty("body", out var body) && body.ValueKind != JsonValueKind.Null; return !hasBody || body.ValueKind == JsonValueKind.Object; @@ -509,258 +511,6 @@ private static string ComputeDocumentRequestDigest( private static string FirstNonEmpty(params string?[] values) => values.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; - private static AgentToolTerminalOutcome BuildReadOutcome( - AgentToolOperationAdmission admission, - string serviceLabel, - string operationLabel, - string callId, - string toolName, - string sourceResult, - AgentToolReceipt? sourceReceipt) - { - var sourceBytes = Encoding.UTF8.GetByteCount(sourceResult ?? string.Empty); - if (sourceBytes > MaxReadSourceBytes || - string.Equals( - sourceReceipt?.ErrorCode, - ProxyResponseTooLargeErrorCode, - StringComparison.Ordinal)) - { - return BuildReadTooLargeOutcome(admission, serviceLabel, operationLabel, callId, toolName); - } - - if (sourceReceipt?.Status != AgentToolReceiptStatus.Success) - { - var result = BuildReadProjection( - admission, - serviceLabel, - operationLabel, - "failed", - data: null, - SafeCode(sourceReceipt?.ErrorCode), - SafeMessage(sourceReceipt?.ErrorMessage)); - var receipt = sourceReceipt?.Clone() ?? NyxIdProxyReceiptFactory.CreateError( - callId, - toolName, - admission.ServiceInstanceId, - "NYXID_CONNECTED_SERVICE_READ_UNVERIFIED", - "The connected-service read result could not be verified.", - result); - receipt.ResultJson = result; - return new AgentToolTerminalOutcome(result, receipt); - } - - JsonNode? data; - try - { - data = JsonNode.Parse(sourceResult ?? string.Empty); - } - catch (JsonException) - { - data = JsonValue.Create(sourceResult); - } - - var projection = BuildReadProjection(admission, serviceLabel, operationLabel, "succeeded", data, null, null); - if (Encoding.UTF8.GetByteCount(projection) > MaxReadSourceBytes) - return BuildReadTooLargeOutcome(admission, serviceLabel, operationLabel, callId, toolName); - - var successReceipt = sourceReceipt.Clone(); - successReceipt.ResultJson = projection; - return new AgentToolTerminalOutcome(projection, successReceipt); - } - - private static AgentToolTerminalOutcome BuildReadTooLargeOutcome( - AgentToolOperationAdmission admission, - string serviceLabel, - string operationLabel, - string callId, - string toolName) - { - var result = BuildBoundedReadTooLargeProjection(admission, serviceLabel, operationLabel); - var receipt = NyxIdProxyReceiptFactory.CreateSuccess( - callId, - toolName, - admission.ServiceInstanceId, - result) ?? throw new InvalidOperationException( - "A connected-service operation must have a valid UserService identity."); - receipt.Effect = AgentToolReceiptEffect.ReadOnly; - return new AgentToolTerminalOutcome(result, receipt); - } - - private static string BuildBoundedReadTooLargeProjection( - AgentToolOperationAdmission admission, - string serviceLabel, - string operationLabel) - { - var result = BuildReadProjection( - admission, - serviceLabel, - operationLabel, - "retry_required", - data: null, - ReadTooLargeErrorCode, - ReadTooLargeErrorMessage, - BuildReadRetryHints(admission, includeQueryParameters: true)); - if (Encoding.UTF8.GetByteCount(result) <= MaxReadSourceBytes) - return result; - - result = BuildReadProjection( - admission, - serviceLabel, - operationLabel, - "retry_required", - data: null, - ReadTooLargeErrorCode, - ReadTooLargeErrorMessage, - BuildReadRetryHints(admission, includeQueryParameters: false)); - return Encoding.UTF8.GetByteCount(result) <= MaxReadSourceBytes - ? result - : BuildReadProjection( - admission, - serviceLabel, - operationLabel, - "retry_required", - data: null, - ReadTooLargeErrorCode, - ReadTooLargeErrorMessage); - } - - private static AgentToolTerminalOutcome BuildEffectOutcome( - AgentToolOperationAdmission admission, - NyxIdConnectedServiceReadBackPlan? readBackPlan, - string serviceLabel, - string operationLabel, - string callId, - string toolName, - AgentToolReceipt? sourceReceipt) - { - var receipt = sourceReceipt?.Clone() ?? NyxIdProxyReceiptFactory.CreateError( - callId, - toolName, - admission.ServiceInstanceId, - "NYXID_CONNECTED_SERVICE_EFFECT_UNVERIFIED", - "The connected-service effect result could not be verified.", - string.Empty); - if (receipt.Status == AgentToolReceiptStatus.Success) - receipt.ProviderResourceId = readBackPlan?.ExtractProviderResourceId(receipt.ResultJson) ?? string.Empty; - - var result = new JsonObject - { - ["kind"] = EffectReceiptKind, - ["status"] = receipt.Status.ToString().ToLowerInvariant(), - ["provenance"] = BuildProvenance(admission, serviceLabel, operationLabel), - ["approval_request_id"] = string.IsNullOrWhiteSpace(receipt.ApprovalRequestId) - ? null - : receipt.ApprovalRequestId, - ["error_code"] = SafeCode(receipt.ErrorCode), - ["error_message"] = SafeMessage(receipt.ErrorMessage), - }.ToJsonString(JsonOptions); - receipt.ResultJson = result; - return new AgentToolTerminalOutcome(result, receipt); - } - - private static string BuildReadProjection( - AgentToolOperationAdmission admission, - string serviceLabel, - string operationLabel, - string status, - JsonNode? data, - string? errorCode, - string? errorMessage, - JsonNode? retryHints = null) => new JsonObject - { - ["kind"] = ReadProjectionKind, - ["status"] = status, - ["provenance"] = BuildProvenance(admission, serviceLabel, operationLabel), - ["content_boundary"] = "untrusted_external_data_only", - ["instructions_allowed"] = false, - ["data"] = data?.DeepClone(), - ["error_code"] = errorCode, - ["error_message"] = errorMessage, - ["retry_hints"] = retryHints?.DeepClone(), - }.ToJsonString(JsonOptions); - - private static JsonObject BuildReadRetryHints( - AgentToolOperationAdmission admission, - bool includeQueryParameters) - { - var result = new JsonObject - { - ["reason"] = "bounded_projection_limit_exceeded", - ["operation_path_template"] = admission.PathTemplate, - ["retry_guidance"] = "Retry the same read with a narrower query, smaller page size, or the next page token when the operation publishes those query parameters.", - }; - if (!includeQueryParameters) - return result; - - result["query_parameters"] = new JsonArray(admission.QueryParameters - .OrderBy(static parameter => parameter.Name, StringComparer.Ordinal) - .Select(parameter => new JsonObject - { - ["name"] = parameter.Name, - ["required"] = parameter.Required, - ["description"] = NyxIdConnectedServiceOperationSchema.BuildModelParameterDescription( - "query", - parameter), - }) - .ToArray()); - return result; - } - - private static JsonObject BuildProvenance( - AgentToolOperationAdmission admission, - string serviceLabel, - string operationLabel) => new() - { - ["source_kind"] = "nyxid_connected_service", - ["operation_selector_digest"] = AgentToolOperationSelector.ComputeDigest(admission), - ["service_label"] = NormalizeLabel(serviceLabel, "Connected service"), - ["operation_label"] = NormalizeLabel(operationLabel, "Operation"), - }; - - private static string NormalizeLabel(string? value, string fallback) - { - if (string.IsNullOrWhiteSpace(value)) - return fallback; - - var builder = new StringBuilder(Math.Min(value.Length, 80)); - var lastWasSpace = false; - foreach (var character in value.Trim()) - { - if (builder.Length >= 80) - break; - var allowed = char.IsLetterOrDigit(character) || character is '-' or '_' or '.' or '/' or ':'; - if (allowed) - { - builder.Append(character); - lastWasSpace = false; - } - else if (!lastWasSpace) - { - builder.Append(' '); - lastWasSpace = true; - } - } - - var normalized = builder.ToString().Trim(); - return normalized.Length == 0 ? fallback : normalized; - } - - private static string? SafeCode(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return null; - var normalized = value.Trim(); - return normalized.Length <= 96 ? normalized : normalized[..96]; - } - - private static string? SafeMessage(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return null; - var normalized = value.Trim(); - return normalized.Length <= 256 ? normalized : normalized[..256]; - } - private async Task> ReadBindingsAsync( AgentToolExecutionContext context, string executionToken, diff --git a/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdConnectedServiceOperationTool.cs b/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdConnectedServiceOperationTool.cs index d37b4bf66..8a6ecaf6b 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdConnectedServiceOperationTool.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/ConnectedServices/NyxIdConnectedServiceOperationTool.cs @@ -58,7 +58,9 @@ internal static class NyxIdConnectedServiceExposurePolicy public static bool Allows(AgentToolOperationAdmission admission) { var policy = admission.ExecutionPolicy; - if (admission.Identity is not AgentToolOperationIdentity.PublishedEndpoint || + if (admission.Identity is not + (AgentToolOperationIdentity.PublishedEndpoint or AgentToolOperationIdentity.AuthoredRequest) || + admission.Identity is AgentToolOperationIdentity.PublishedEndpoint && string.IsNullOrWhiteSpace(admission.CatalogDigest) || policy.EnforcementOwner != AgentToolOperationEnforcementOwner.Aevatar || !policy.AllowedExecutionModes.Contains(AgentToolOperationExecutionMode.Interactive)) @@ -74,7 +76,9 @@ admission.HttpMethod is "GET" or "HEAD" or "OPTIONS" && AgentToolOperationRisk.Write => admission.HttpMethod is "POST" or "PUT" or "PATCH" && policy.Approval is AgentToolOperationApproval.None or AgentToolOperationApproval.Required, - AgentToolOperationRisk.Destructive => false, + AgentToolOperationRisk.Destructive => + admission.HttpMethod == "DELETE" && + policy.Approval is AgentToolOperationApproval.None or AgentToolOperationApproval.Required, _ => false, }; } @@ -84,8 +88,8 @@ internal sealed class NyxIdConnectedServiceOperationTool : IAgentTool, IAgentToolOperationAdmissionOwner { - private const int MaxReadSourceBytes = 256 * 1024; - private const int MaxReadProjectionBytes = 256 * 1024; + private const int DefaultMaxReadSourceBytes = 256 * 1024; + private const int DefaultMaxReadProjectionBytes = 256 * 1024; private const int MaxSafeLabelLength = 80; private const string ProxyResponseTooLargeErrorCode = "NYXID_PROXY_RESPONSE_TOO_LARGE"; private const string ReadTooLargeErrorCode = "NYXID_CONNECTED_SERVICE_READ_TOO_LARGE"; @@ -105,6 +109,8 @@ internal sealed class NyxIdConnectedServiceOperationTool : private readonly string _operationLabel; private readonly NyxIdServiceAccessTokenSource _accessTokenSource; private readonly NyxIdConnectedServiceReadBackPlan? _readBackPlan; + private readonly int _maxReadSourceBytes; + private readonly int _maxReadProjectionBytes; public NyxIdConnectedServiceOperationTool( NyxIdProxyTool proxy, @@ -114,7 +120,9 @@ public NyxIdConnectedServiceOperationTool( string connectionLabel, string? readinessCapabilityId, NyxIdServiceAccessTokenSource accessTokenSource, - NyxIdConnectedServiceReadBackPlan? readBackPlan = null) + NyxIdConnectedServiceReadBackPlan? readBackPlan = null, + int maxReadSourceBytes = DefaultMaxReadSourceBytes, + int maxReadProjectionBytes = DefaultMaxReadProjectionBytes) { _proxy = proxy ?? throw new ArgumentNullException(nameof(proxy)); OperationAdmission = admission ?? throw new ArgumentNullException(nameof(admission)); @@ -126,6 +134,8 @@ public NyxIdConnectedServiceOperationTool( _operationLabel = NormalizeModelLabel(operationLabel, "Operation", selectorSecrets); _accessTokenSource = accessTokenSource; _readBackPlan = readBackPlan; + _maxReadSourceBytes = maxReadSourceBytes; + _maxReadProjectionBytes = maxReadProjectionBytes; Name = BuildOpaqueName(admission); ParametersSchema = NyxIdConnectedServiceOperationSchema.Build(admission); Presentation = BuildPresentation( @@ -154,7 +164,8 @@ public NyxIdConnectedServiceOperationTool( private bool RequiresOperationApproval => OperationAdmission.ExecutionPolicy.Approval == AgentToolOperationApproval.Required; - public bool IsDestructive => false; + public bool IsDestructive => + OperationAdmission.ExecutionPolicy.Risk == AgentToolOperationRisk.Destructive; public string SideEffectKind => IsReadOnly ? string.Empty : "connected_service_operation"; @@ -197,7 +208,7 @@ private ToolPresentationDescriptor BuildPresentation( public AgentToolCallSafety GetCallSafety(string argumentsJson) => new( RequiresApproval: RequiresOperationApproval, IsReadOnly, - IsDestructive: false); + IsDestructive); public AgentToolOperationAdmission ResolveOperationAdmission(string argumentsJson) => _readBackPlan?.TryFreeze(argumentsJson, out var readBack) == true @@ -241,7 +252,7 @@ public async Task ExecuteWithOutcomeAsync( callId, toolName, argumentsJson, - MaxReadSourceBytes, + _maxReadSourceBytes, ct) : await _proxy.ExecuteAdmittedEffectWithOutcomeAsync( callId, @@ -265,7 +276,7 @@ private AgentToolTerminalOutcome BuildReadOutcome( AgentToolReceipt? sourceReceipt) { var sourceBytes = Encoding.UTF8.GetByteCount(sourceResult ?? string.Empty); - if (sourceBytes > MaxReadSourceBytes || + if (sourceBytes > _maxReadSourceBytes || string.Equals( sourceReceipt?.ErrorCode, ProxyResponseTooLargeErrorCode, @@ -303,7 +314,7 @@ private AgentToolTerminalOutcome BuildReadOutcome( } var projection = BuildReadProjection("succeeded", data, null, null); - if (Encoding.UTF8.GetByteCount(projection) > MaxReadProjectionBytes) + if (Encoding.UTF8.GetByteCount(projection) > _maxReadProjectionBytes) return BuildReadTooLargeOutcome(callId, toolName); var successReceipt = sourceReceipt.Clone(); @@ -336,7 +347,7 @@ private string BuildBoundedReadTooLargeProjection() ReadTooLargeErrorCode, ReadTooLargeErrorMessage, BuildReadRetryHints(includeQueryParameters: true)); - if (Encoding.UTF8.GetByteCount(result) <= MaxReadProjectionBytes) + if (Encoding.UTF8.GetByteCount(result) <= _maxReadProjectionBytes) return result; result = BuildReadProjection( @@ -345,7 +356,7 @@ private string BuildBoundedReadTooLargeProjection() ReadTooLargeErrorCode, ReadTooLargeErrorMessage, BuildReadRetryHints(includeQueryParameters: false)); - return Encoding.UTF8.GetByteCount(result) <= MaxReadProjectionBytes + return Encoding.UTF8.GetByteCount(result) <= _maxReadProjectionBytes ? result : BuildReadProjection( "retry_required", diff --git a/src/Aevatar.AI.ToolProviders.NyxId/NyxIdProxyHeaderPolicy.cs b/src/Aevatar.AI.ToolProviders.NyxId/NyxIdProxyHeaderPolicy.cs index 44523b1d6..7b635ff48 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/NyxIdProxyHeaderPolicy.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/NyxIdProxyHeaderPolicy.cs @@ -8,7 +8,7 @@ public static bool IsSensitive(string headerName) .Where(char.IsLetterOrDigit) .Select(char.ToLowerInvariant) .ToArray()); - return normalized is "authorization" or "proxyauthorization" or "cookie" or "setcookie" or + return normalized is "authorization" or "proxyauthorization" or "cookie" or "setcookie" or "host" or "apikey" or "xapikey" or "token" or "apitoken" or "xauthtoken" or "accesstoken" or "xaccesstoken" or "bearertoken" || normalized.EndsWith("apikey", StringComparison.Ordinal) || diff --git a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdProxyTool.cs b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdProxyTool.cs index cf0c6a89b..e462e1c47 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdProxyTool.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdProxyTool.cs @@ -247,7 +247,8 @@ internal async Task ExecuteAdmittedEffectWithOutcomeAs CancellationToken ct = default) { var admission = AgentToolRequestContext.Current?.OperationAdmission; - if (admission?.ExecutionPolicy.Risk != AgentToolOperationRisk.Write || + if (admission?.ExecutionPolicy.Risk is not + (AgentToolOperationRisk.Write or AgentToolOperationRisk.Destructive) || admission.ExecutionPolicy.Approval is not (AgentToolOperationApproval.None or AgentToolOperationApproval.Required)) { diff --git a/test/Aevatar.AI.Tests/NyxIdProxyToolAdmittedOperationTests.cs b/test/Aevatar.AI.Tests/NyxIdProxyToolAdmittedOperationTests.cs index 6a10196ac..c962048e2 100644 --- a/test/Aevatar.AI.Tests/NyxIdProxyToolAdmittedOperationTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdProxyToolAdmittedOperationTests.cs @@ -1725,6 +1725,29 @@ public async Task ExecuteAsync_ShouldAcceptProofBoundDurableWritePolicyInEnforce handler.ProxyRequests.Should().ContainSingle(); } + [Fact] + public async Task ExecuteAsync_ShouldAcceptProofBoundDestructivePolicyInEnforceMode() + { + var handler = new RecordingHandler(); + var tool = CreateTool( + handler, + managedWorkflowAdmissionMode: NyxIdManagedWorkflowAdmissionMode.Enforce); + using var scope = PushContext(AuthoredRequestAdmission() with + { + HttpMethod = "DELETE", + PathTemplate = "/events/{event_id}", + RequestBody = null, + ExecutionPolicy = DestructivePolicy(), + }); + + var result = await tool.ExecuteAsync( + """{"path_params":{"event_id":"evt-runtime"}}"""); + + result.Should().NotContain("NYXID_OPERATION_ADMISSION_REQUIRED"); + handler.ProxyRequests.Should().ContainSingle() + .Which.Method.Should().Be("DELETE"); + } + private static AgentToolOperationAdmission MessageResourceAdmission() => new( "us-lark-alpha", diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs index a12300b39..611ed6d8c 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelNyxIdConnectedServiceInventoryToolSourceTests.cs @@ -1150,6 +1150,156 @@ public async Task InvokeOperationAsync_WithDocumentGuidedRequest_ExecutesConstra proxyRequest.BearerToken.Should().Be("registration-agent-key"); } + [Fact] + public async Task InvokeOperationAsync_WithRawDelegatedRead_ExecutesProxyWithoutOpenApiOrRecommendedSkillRef() + { + var handler = new InventoryHandler + { + KeysResponse = KeysWithGoogleWorkspace(), + ProxyResponseBody = "{\"matches\":[\"policy-a\"]}", + }; + var options = new NyxIdToolOptions { BaseUrl = "https://nyx.test" }; + var source = new ChannelNyxIdConnectedServiceInventoryToolSource( + new RecordingExecutionPort(), + options, + new TestNyxIdApiClientFactory(new NyxIdApiClient(options, new HttpClient(handler))), + Substitute.For()); + using var scope = AgentToolContextScope.Push(CreateRegistrationContext(hasSenderBinding: false)); + var tool = OperationTool(await source.DiscoverToolsAsync()); + + var result = await tool.ExecuteAsync(""" + { + "service_slug":"api-google-workspace", + "method":"GET", + "relative_path":"/docs/policies", + "query":{"restaurant":"north"}, + "headers":{"Accept":"application/json"} + } + """); + + result.Should().Contain("policy-a"); + handler.RawOpenApiRequests.Should().BeEmpty(); + var proxyRequest = handler.ProxyRequests.Should().ContainSingle().Subject; + proxyRequest.Method.Should().Be("GET"); + proxyRequest.Path.Should().Contain("/docs/policies"); + proxyRequest.Query.Should().Contain("restaurant=north"); + proxyRequest.BearerToken.Should().Be("registration-agent-key"); + } + + [Fact] + public async Task InvokeOperationAsync_WithRawDelegatedRead_UsesSenderBoundCredential() + { + var handler = new InventoryHandler + { + KeysResponse = KeysWithGoogleWorkspace(), + ProxyResponseBody = "{\"matches\":[\"policy-a\"]}", + }; + var options = new NyxIdToolOptions { BaseUrl = "https://nyx.test" }; + var issuer = Substitute.For(); + issuer.IssueByBindingIdAsync( + Arg.Any(), + "bnd-sender-1", + Arg.Any()) + .Returns(new CapabilityHandle { AccessToken = "strict-sender-token", Scope = "proxy" }); + var source = new ChannelNyxIdConnectedServiceInventoryToolSource( + new RecordingExecutionPort(), + options, + new TestNyxIdApiClientFactory(new NyxIdApiClient(options, new HttpClient(handler))), + issuer); + using var scope = AgentToolContextScope.Push(CreateRegistrationContext() with + { + Credentials = new AgentToolCredentials( + "bot-owner-token", "bot-owner-org-token", "strict-sender-token"), + }); + var tool = OperationTool(await source.DiscoverToolsAsync()); + + var result = await tool.ExecuteAsync(""" + { + "service_slug":"api-google-workspace", + "method":"GET", + "relative_path":"/docs/policies", + "query":{"restaurant":"north"} + } + """); + + result.Should().Contain("policy-a"); + handler.RawOpenApiRequests.Should().BeEmpty(); + var proxyRequest = handler.ProxyRequests.Should().ContainSingle().Subject; + proxyRequest.BearerToken.Should().Be("strict-sender-token"); + await issuer.Received(1).IssueByBindingIdAsync( + Arg.Any(), + "bnd-sender-1", + Arg.Any()); + } + + [Theory] + [InlineData("https://evil.test/docs")] + [InlineData("/docs/policies?restaurant=north")] + [InlineData("/docs/policies#section")] + [InlineData("/docs/../secrets")] + public async Task InvokeOperationAsync_WithUnsafeRawDelegatedPath_RejectsWithoutProxyRequest(string relativePath) + { + var handler = new InventoryHandler + { + KeysResponse = KeysWithGoogleWorkspace(), + }; + var options = new NyxIdToolOptions { BaseUrl = "https://nyx.test" }; + var source = new ChannelNyxIdConnectedServiceInventoryToolSource( + new RecordingExecutionPort(), + options, + new TestNyxIdApiClientFactory(new NyxIdApiClient(options, new HttpClient(handler))), + Substitute.For()); + using var scope = AgentToolContextScope.Push(CreateRegistrationContext(hasSenderBinding: false)); + var tool = OperationTool(await source.DiscoverToolsAsync()); + + var result = await tool.ExecuteAsync($$""" + { + "service_slug":"api-google-workspace", + "method":"GET", + "relative_path":"{{relativePath}}" + } + """); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("error").GetString().Should().Be("raw_request_invalid"); + handler.RawOpenApiRequests.Should().BeEmpty(); + handler.ProxyRequests.Should().BeEmpty(); + } + + [Theory] + [InlineData("Authorization")] + [InlineData("Host")] + [InlineData("X-Api-Key")] + public async Task InvokeOperationAsync_WithRawDelegatedSensitiveHeader_RejectsWithoutProxyRequest(string headerName) + { + var handler = new InventoryHandler + { + KeysResponse = KeysWithGoogleWorkspace(), + }; + var options = new NyxIdToolOptions { BaseUrl = "https://nyx.test" }; + var source = new ChannelNyxIdConnectedServiceInventoryToolSource( + new RecordingExecutionPort(), + options, + new TestNyxIdApiClientFactory(new NyxIdApiClient(options, new HttpClient(handler))), + Substitute.For()); + using var scope = AgentToolContextScope.Push(CreateRegistrationContext(hasSenderBinding: false)); + var tool = OperationTool(await source.DiscoverToolsAsync()); + + var result = await tool.ExecuteAsync($$""" + { + "service_slug":"api-google-workspace", + "method":"GET", + "relative_path":"/docs/policies", + "headers":{"{{headerName}}":"secret"} + } + """); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("error").GetString().Should().Be("raw_request_invalid"); + handler.RawOpenApiRequests.Should().BeEmpty(); + handler.ProxyRequests.Should().BeEmpty(); + } + [Fact] public async Task InvokeOperationAsync_WithDocumentGuidedRequestWithoutRecommendedSkillRef_RejectsWithoutProxyRequest() { @@ -1709,16 +1859,17 @@ protected override Task SendAsync( CancellationToken cancellationToken) { Authorization = request.Headers.Authorization?.ToString(); - RequestPath = request.RequestUri?.AbsolutePath; + var requestPath = request.RequestUri?.AbsolutePath ?? string.Empty; + RequestPath = requestPath; ExecutionContext = AgentToolRequestContext.Current; request.Headers.TryGetValues("X-API-Key", out var apiKeyValues); var apiKey = apiKeyValues?.SingleOrDefault() ?? string.Empty; if (request.Method == HttpMethod.Get && - RequestPath.StartsWith("/api/v1/catalog-specs/", StringComparison.Ordinal) && - RequestPath.EndsWith("/openapi.json", StringComparison.Ordinal)) + requestPath.StartsWith("/api/v1/catalog-specs/", StringComparison.Ordinal) && + requestPath.EndsWith("/openapi.json", StringComparison.Ordinal)) { - RawOpenApiRequests.Add(RequestPath); - if (OpenApiResponsesByPath.TryGetValue(RequestPath, out var openApiResponse)) + RawOpenApiRequests.Add(requestPath); + if (OpenApiResponsesByPath.TryGetValue(requestPath, out var openApiResponse)) { return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { @@ -1729,11 +1880,11 @@ protected override Task SendAsync( throw new InvalidOperationException("catalog_openapi_must_be_configured"); } - if (RequestPath.StartsWith("/api/v1/proxy/", StringComparison.Ordinal)) + if (requestPath.StartsWith("/api/v1/proxy/", StringComparison.Ordinal)) { ProxyRequests.Add(new ProxyRequestRecord( request.Method.Method, - RequestPath, + requestPath, request.RequestUri?.Query ?? string.Empty, request.Headers.Authorization?.Parameter ?? string.Empty, apiKey)); @@ -1743,7 +1894,7 @@ protected override Task SendAsync( }); } - var response = RequestPath switch + var response = requestPath switch { "/api/v1/user-services" => """ {"services":[{"id":"user-service-1","slug":"github","label":"GitHub",