a2a-rails 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -3
- data/README.md +45 -25
- data/lib/a2a/rails/client/agent_card_resolver.rb +190 -0
- data/lib/a2a/rails/client/codec.rb +106 -0
- data/lib/a2a/rails/client/outbound_policy.rb +196 -0
- data/lib/a2a/rails/client/pinned_https_transport.rb +227 -0
- data/lib/a2a/rails/client/public_types.rb +58 -0
- data/lib/a2a/rails/client.rb +295 -0
- data/lib/a2a/rails/configuration.rb +6 -2
- data/lib/a2a/rails/engine.rb +4 -1
- data/lib/a2a/rails/version.rb +1 -1
- data/lib/a2a-rails.rb +1 -0
- metadata +13 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "resolv"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
|
|
9
|
+
module A2A
|
|
10
|
+
module Rails
|
|
11
|
+
class Client
|
|
12
|
+
# Preflight checks for a *configured* outbound Agent Card or RPC URL.
|
|
13
|
+
#
|
|
14
|
+
# IMPORTANT: Resolve! alone does not prevent DNS rebinding. The future
|
|
15
|
+
# transport MUST connect to one of Target#addresses while preserving
|
|
16
|
+
# the validated Host/SNI, and repeat the decision for each connection.
|
|
17
|
+
# Do not expose this as a production-ready HTTP Client.
|
|
18
|
+
class OutboundPolicy
|
|
19
|
+
class RejectedTarget < A2A::Rails::Error
|
|
20
|
+
attr_reader :reason
|
|
21
|
+
|
|
22
|
+
def initialize(reason)
|
|
23
|
+
@reason = reason
|
|
24
|
+
super("Outbound A2A target rejected: #{reason}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Target = Struct.new(:url, :origin, :host, :port, :addresses, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
# IPv4-pinned policy. Dual-stack DNS is accepted only if EVERY IPv6
|
|
31
|
+
# answer is a recognized public global-unicast candidate and at least
|
|
32
|
+
# one safe IPv4 address is present. IPv6 sockets remain unsupported.
|
|
33
|
+
BLOCKED_IPV4 = %w[
|
|
34
|
+
0.0.0.0/8
|
|
35
|
+
10.0.0.0/8
|
|
36
|
+
100.64.0.0/10
|
|
37
|
+
127.0.0.0/8
|
|
38
|
+
169.254.0.0/16
|
|
39
|
+
172.16.0.0/12
|
|
40
|
+
192.0.0.0/24
|
|
41
|
+
192.0.2.0/24
|
|
42
|
+
192.88.99.0/24
|
|
43
|
+
192.168.0.0/16
|
|
44
|
+
198.18.0.0/15
|
|
45
|
+
198.51.100.0/24
|
|
46
|
+
203.0.113.0/24
|
|
47
|
+
224.0.0.0/4
|
|
48
|
+
240.0.0.0/4
|
|
49
|
+
].map { |cidr| IPAddr.new(cidr) }.freeze
|
|
50
|
+
|
|
51
|
+
# Only recognize IPv6 global-unicast candidates for DNS validation;
|
|
52
|
+
# outbound sockets remain IPv4-only. Exclude special-use allocations
|
|
53
|
+
# conservatively, including IPv4-embedding transition mechanisms.
|
|
54
|
+
GLOBAL_UNICAST_IPV6 = IPAddr.new("2000::/3")
|
|
55
|
+
BLOCKED_IPV6 = %w[
|
|
56
|
+
2001::/23
|
|
57
|
+
2001:db8::/32
|
|
58
|
+
2002::/16
|
|
59
|
+
3fff::/20
|
|
60
|
+
].map { |cidr| IPAddr.new(cidr) }.freeze
|
|
61
|
+
|
|
62
|
+
HOST_LABEL = /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/
|
|
63
|
+
|
|
64
|
+
# Resolver injection is for unit testing only; normal callers use
|
|
65
|
+
# Resolv. The Client facade will not expose this override by default.
|
|
66
|
+
def initialize(allowed_origins:, resolver: Resolv.method(:getaddresses))
|
|
67
|
+
unless allowed_origins.is_a?(Array) && !allowed_origins.empty? && allowed_origins.all? { |value| value.is_a?(String) }
|
|
68
|
+
raise RejectedTarget, :invalid_allowlist
|
|
69
|
+
end
|
|
70
|
+
unless resolver.respond_to?(:call)
|
|
71
|
+
raise RejectedTarget, :invalid_resolver
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@allowed_origins = allowed_origins.map { |value| checked_origin(value) }.uniq.freeze
|
|
75
|
+
@resolver = resolver
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check configured URL syntax and exact origin without resolving DNS.
|
|
79
|
+
# The actual HTTP transport MUST call resolve! again at connect time.
|
|
80
|
+
def validate_url!(value)
|
|
81
|
+
uri = checked_uri(value)
|
|
82
|
+
raise RejectedTarget, :unapproved_origin unless @allowed_origins.include?(canonical_origin(uri))
|
|
83
|
+
|
|
84
|
+
uri
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Called for BOTH the configured Agent Card URL and each card-provided
|
|
88
|
+
# supportedInterfaces[].url. The result is NOT permission to connect
|
|
89
|
+
# by hostname: use Target#addresses as the connect-time allowlist.
|
|
90
|
+
def resolve!(value)
|
|
91
|
+
uri = checked_uri(value)
|
|
92
|
+
origin = canonical_origin(uri)
|
|
93
|
+
raise RejectedTarget, :unapproved_origin unless @allowed_origins.include?(origin)
|
|
94
|
+
|
|
95
|
+
addresses = begin
|
|
96
|
+
@resolver.call(uri.host)
|
|
97
|
+
rescue Timeout::Error
|
|
98
|
+
# The transport's total deadline must not be converted into a DNS failure.
|
|
99
|
+
raise
|
|
100
|
+
rescue StandardError
|
|
101
|
+
raise RejectedTarget.new(:dns_failure), cause: nil
|
|
102
|
+
end
|
|
103
|
+
unless addresses.is_a?(Array) && !addresses.empty?
|
|
104
|
+
raise RejectedTarget, :dns_failure
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Fail closed if ANY DNS answer is unsafe, even when another answer
|
|
108
|
+
# is public. Public IPv6 answers are checked, but NEVER selected as
|
|
109
|
+
# a socket destination; the transport pins an approved IPv4 only.
|
|
110
|
+
addresses = addresses.each_with_object([]) do |address, ipv4_addresses|
|
|
111
|
+
# IPAddr accepts CIDR strings and normalizes them to network addresses.
|
|
112
|
+
# DNS answers must instead be individual host IP literals.
|
|
113
|
+
unless address.is_a?(String) && !address.include?("/")
|
|
114
|
+
raise RejectedTarget, :invalid_dns_address
|
|
115
|
+
end
|
|
116
|
+
parsed = IPAddr.new(address)
|
|
117
|
+
if parsed.ipv4?
|
|
118
|
+
raise RejectedTarget, :blocked_ip if BLOCKED_IPV4.any? { |range| range.include?(parsed) }
|
|
119
|
+
ipv4_addresses << parsed.to_s.freeze
|
|
120
|
+
elsif !GLOBAL_UNICAST_IPV6.include?(parsed) ||
|
|
121
|
+
BLOCKED_IPV6.any? { |range| range.include?(parsed) }
|
|
122
|
+
raise RejectedTarget, :blocked_ip
|
|
123
|
+
end
|
|
124
|
+
rescue IPAddr::InvalidAddressError, ArgumentError
|
|
125
|
+
raise RejectedTarget.new(:invalid_dns_address), cause: nil
|
|
126
|
+
end.uniq.freeze
|
|
127
|
+
raise RejectedTarget, :unsupported_ip_family if addresses.empty?
|
|
128
|
+
|
|
129
|
+
Target.new(
|
|
130
|
+
url: uri.to_s.freeze,
|
|
131
|
+
origin: origin.freeze,
|
|
132
|
+
host: uri.host.freeze,
|
|
133
|
+
port: uri.port,
|
|
134
|
+
addresses: addresses
|
|
135
|
+
).freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def checked_origin(value)
|
|
141
|
+
uri = checked_uri(value)
|
|
142
|
+
raise RejectedTarget, :invalid_allowlist unless uri.path.empty? || uri.path == "/"
|
|
143
|
+
|
|
144
|
+
canonical_origin(uri)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def checked_uri(value)
|
|
148
|
+
raise RejectedTarget, :invalid_url unless value.is_a?(String) && !value.empty? && value == value.strip
|
|
149
|
+
uri = URI.parse(value)
|
|
150
|
+
|
|
151
|
+
unless uri.is_a?(URI::HTTPS) && uri.host && !uri.host.empty? &&
|
|
152
|
+
uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
|
|
153
|
+
raise RejectedTarget, :invalid_url
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
host = uri.host.downcase
|
|
157
|
+
labels = host.split(".", -1)
|
|
158
|
+
unless host.ascii_only? && host.bytesize <= 253 && labels.size >= 2 &&
|
|
159
|
+
labels.all? { |part| HOST_LABEL.match?(part) } &&
|
|
160
|
+
labels.last.match?(/[a-z]/)
|
|
161
|
+
raise RejectedTarget, :invalid_host
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# DNS names only, never direct IPv4/IPv6 or unusual numeric forms.
|
|
165
|
+
begin
|
|
166
|
+
IPAddr.new(host)
|
|
167
|
+
raise RejectedTarget, :ip_literal
|
|
168
|
+
rescue IPAddr::InvalidAddressError
|
|
169
|
+
# Expected for DNS names.
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Fail closed on encoded slashes, backslashes, controls or ambiguous
|
|
173
|
+
# dot segments pending a more general URL canonicalization layer.
|
|
174
|
+
path = uri.path
|
|
175
|
+
if path.match?(/[%\\\\[:cntrl:]]/) || path.split("/").include?("..") || path.split("/").include?(".")
|
|
176
|
+
raise RejectedTarget, :invalid_path
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
unless uri.port.between?(1, 65_535)
|
|
180
|
+
raise RejectedTarget, :invalid_port
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
uri.host = host
|
|
184
|
+
uri
|
|
185
|
+
rescue URI::InvalidURIError, URI::InvalidComponentError
|
|
186
|
+
raise RejectedTarget.new(:invalid_url), cause: nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def canonical_origin(uri)
|
|
190
|
+
base = "https://#{uri.host.downcase}"
|
|
191
|
+
uri.port == 443 ? base : "#{base}:#{uri.port}"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "uri"
|
|
8
|
+
require_relative "outbound_policy"
|
|
9
|
+
|
|
10
|
+
module A2A
|
|
11
|
+
module Rails
|
|
12
|
+
class Client
|
|
13
|
+
# Internal synchronous HTTPS transport used by the unreleased Client facade.
|
|
14
|
+
#
|
|
15
|
+
# DNS is checked immediately before each request; the selected IP is
|
|
16
|
+
# pinned on Net::HTTP BEFORE the socket is opened. TLS peer and hostname
|
|
17
|
+
# validation continue to use the original approved DNS hostname.
|
|
18
|
+
class PinnedHttpsTransport
|
|
19
|
+
class Error < A2A::Rails::Error
|
|
20
|
+
attr_reader :reason
|
|
21
|
+
|
|
22
|
+
def initialize(reason)
|
|
23
|
+
@reason = reason
|
|
24
|
+
super("Outbound A2A HTTP request failed: #{reason}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class RedirectBlocked < Error; end
|
|
29
|
+
class ResponseTooLarge < Error; end
|
|
30
|
+
class InvalidResponse < Error; end
|
|
31
|
+
class DeadlineExceeded < Error; end
|
|
32
|
+
|
|
33
|
+
class HTTPError < Error
|
|
34
|
+
attr_reader :status
|
|
35
|
+
|
|
36
|
+
def initialize(status)
|
|
37
|
+
@status = status
|
|
38
|
+
super(:http_error)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Response = Struct.new(:status, :json, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
DEFAULT_OPEN_TIMEOUT = 3
|
|
45
|
+
DEFAULT_READ_TIMEOUT = 10
|
|
46
|
+
DEFAULT_TOTAL_TIMEOUT = 15
|
|
47
|
+
DEFAULT_MAX_BYTES = 1_048_576
|
|
48
|
+
DEFAULT_MAX_DEPTH = 48
|
|
49
|
+
MAX_CREDENTIAL_BYTES = 4096
|
|
50
|
+
|
|
51
|
+
def initialize(policy:, open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
52
|
+
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
53
|
+
total_timeout: DEFAULT_TOTAL_TIMEOUT,
|
|
54
|
+
max_request_bytes: DEFAULT_MAX_BYTES,
|
|
55
|
+
max_response_bytes: DEFAULT_MAX_BYTES,
|
|
56
|
+
max_json_nesting: DEFAULT_MAX_DEPTH,
|
|
57
|
+
ca_file: nil)
|
|
58
|
+
unless policy.respond_to?(:resolve!)
|
|
59
|
+
raise Error, :invalid_policy
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@policy = policy
|
|
63
|
+
@open_timeout = positive_number!(open_timeout)
|
|
64
|
+
@read_timeout = positive_number!(read_timeout)
|
|
65
|
+
@total_timeout = positive_number!(total_timeout)
|
|
66
|
+
@max_request_bytes = positive_integer!(max_request_bytes)
|
|
67
|
+
@max_response_bytes = positive_integer!(max_response_bytes)
|
|
68
|
+
@max_json_nesting = positive_integer!(max_json_nesting)
|
|
69
|
+
@ca_file = ca_file
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Agent Card requests are public by default. Protected Cards require
|
|
73
|
+
# their OWN callback and explicitly bound credential origin.
|
|
74
|
+
def get_json(url:, authorization: nil, credential_origin: nil)
|
|
75
|
+
perform(:get, url: url, authorization: authorization, credential_origin: credential_origin)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A2A v1.0 JSON-RPC payload; preserves both Task and direct Message
|
|
79
|
+
# response shapes without interpreting the protocol at this layer.
|
|
80
|
+
def post_json(url:, json:, authorization: nil, credential_origin: nil)
|
|
81
|
+
encoded = JSON.generate(json, max_nesting: @max_json_nesting, allow_nan: false)
|
|
82
|
+
raise Error, :request_too_large if encoded.bytesize > @max_request_bytes
|
|
83
|
+
|
|
84
|
+
perform(:post, url: url, body: encoded,
|
|
85
|
+
authorization: authorization, credential_origin: credential_origin)
|
|
86
|
+
rescue JSON::GeneratorError, JSON::NestingError, TypeError
|
|
87
|
+
raise Error.new(:invalid_request_json), cause: nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def perform(method, url:, body: nil, authorization: nil, credential_origin: nil)
|
|
93
|
+
# Fail closed on syntax/origin/DNS before evaluating any credential.
|
|
94
|
+
# OutboundPolicy also rejects mixed public/private DNS results.
|
|
95
|
+
# DNS and credential callbacks count toward the same total deadline
|
|
96
|
+
# as socket connection, request upload and response consumption.
|
|
97
|
+
Timeout.timeout(@total_timeout) do
|
|
98
|
+
target = @policy.resolve!(url)
|
|
99
|
+
uri = URI.parse(target.url)
|
|
100
|
+
raise Error, :invalid_target unless uri.is_a?(URI::HTTPS) &&
|
|
101
|
+
target.host == uri.host && target.port == uri.port &&
|
|
102
|
+
target.addresses.is_a?(Array) && !target.addresses.empty?
|
|
103
|
+
|
|
104
|
+
header = credential_for(target, authorization, credential_origin)
|
|
105
|
+
ip = target.addresses.first
|
|
106
|
+
|
|
107
|
+
# The third argument nil is essential: ignore HTTP(S)_PROXY and
|
|
108
|
+
# env proxy configuration, which could bypass the approved IP.
|
|
109
|
+
http = Net::HTTP.new(target.host, target.port, nil)
|
|
110
|
+
raise Error, :proxy_not_disabled if http.proxy?
|
|
111
|
+
|
|
112
|
+
http.ipaddr = ip
|
|
113
|
+
http.use_ssl = true
|
|
114
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
115
|
+
http.verify_hostname = true
|
|
116
|
+
http.ca_file = @ca_file unless @ca_file.nil?
|
|
117
|
+
http.open_timeout = [@open_timeout, @total_timeout].min
|
|
118
|
+
http.read_timeout = [@read_timeout, @total_timeout].min
|
|
119
|
+
http.write_timeout = [@read_timeout, @total_timeout].min
|
|
120
|
+
http.max_retries = 0 # Especially important for SendMessage.
|
|
121
|
+
http.keep_alive_timeout = 0
|
|
122
|
+
|
|
123
|
+
request_class = method == :get ? Net::HTTP::Get : Net::HTTP::Post
|
|
124
|
+
request = request_class.new(uri.request_uri)
|
|
125
|
+
request["Accept"] = "application/json"
|
|
126
|
+
request["Accept-Encoding"] = "identity"
|
|
127
|
+
request["A2A-Version"] = "1.0" if method == :post
|
|
128
|
+
request["Content-Type"] = "application/json" if method == :post
|
|
129
|
+
request["Authorization"] = header unless header.nil?
|
|
130
|
+
request.body = body if method == :post
|
|
131
|
+
|
|
132
|
+
payload = nil
|
|
133
|
+
status = nil
|
|
134
|
+
http.start do |connection|
|
|
135
|
+
# Guard against a future Net::HTTP implementation that might
|
|
136
|
+
# silently connect anywhere other than the pinned address.
|
|
137
|
+
raise Error, :socket_address_mismatch unless connection.ipaddr == ip
|
|
138
|
+
|
|
139
|
+
connection.request(request) do |response|
|
|
140
|
+
status = response.code.to_i
|
|
141
|
+
raise RedirectBlocked, :redirect_rejected if status.between?(300, 399)
|
|
142
|
+
raise HTTPError, status unless status.between?(200, 299)
|
|
143
|
+
|
|
144
|
+
content_type = response["Content-Type"].to_s.downcase.split(";", 2).first.strip
|
|
145
|
+
unless ["application/json", "application/a2a+json"].include?(content_type)
|
|
146
|
+
raise InvalidResponse, :invalid_content_type
|
|
147
|
+
end
|
|
148
|
+
unless response["Content-Encoding"].to_s.strip.downcase.match?(/\A(?:|identity)\z/)
|
|
149
|
+
raise InvalidResponse, :unsupported_content_encoding
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
content_length = response["Content-Length"]
|
|
153
|
+
if content_length
|
|
154
|
+
raise InvalidResponse, :invalid_content_length unless content_length.match?(/\A[0-9]+\z/)
|
|
155
|
+
raise ResponseTooLarge, :response_too_large if content_length.to_i > @max_response_bytes
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
payload = +""
|
|
159
|
+
response.read_body do |chunk|
|
|
160
|
+
raise ResponseTooLarge, :response_too_large if payload.bytesize + chunk.bytesize > @max_response_bytes
|
|
161
|
+
payload << chunk
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Net::HTTP can return successfully on a prematurely closed
|
|
165
|
+
# Content-Length body. A valid JSON prefix must not make that
|
|
166
|
+
# truncated HTTP response look like a complete A2A reply.
|
|
167
|
+
if content_length && payload.bytesize != content_length.to_i
|
|
168
|
+
raise InvalidResponse, :invalid_content_length
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
parsed = JSON.parse(payload, max_nesting: @max_json_nesting)
|
|
174
|
+
Response.new(status: status, json: parsed).freeze
|
|
175
|
+
end
|
|
176
|
+
rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
177
|
+
raise DeadlineExceeded.new(:timeout), cause: nil
|
|
178
|
+
rescue JSON::ParserError, JSON::NestingError
|
|
179
|
+
raise InvalidResponse.new(:invalid_json), cause: nil
|
|
180
|
+
rescue OpenSSL::SSL::SSLError, IOError, SystemCallError, SocketError, EOFError,
|
|
181
|
+
Net::HTTPBadResponse, Net::ProtocolError
|
|
182
|
+
# Never surface remote bodies, headers, URLs, IPs or credentials,
|
|
183
|
+
# including nested exception causes captured by error reporters.
|
|
184
|
+
raise Error.new(:connection_failed), cause: nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def credential_for(target, callback, origin)
|
|
188
|
+
if callback.nil?
|
|
189
|
+
raise Error, :unexpected_credential_origin unless origin.nil?
|
|
190
|
+
return nil
|
|
191
|
+
end
|
|
192
|
+
unless callback.respond_to?(:call) && origin.is_a?(String) && origin == target.origin
|
|
193
|
+
raise Error, :credential_origin_mismatch
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
value = begin
|
|
197
|
+
callback.call
|
|
198
|
+
rescue Timeout::Error
|
|
199
|
+
# Preserve the outer total-deadline classification.
|
|
200
|
+
raise
|
|
201
|
+
rescue StandardError
|
|
202
|
+
# Host token-provider exceptions may embed credentials. Never
|
|
203
|
+
# surface them as the transport's public-facing error/cause.
|
|
204
|
+
raise Error.new(:credential_failure), cause: nil
|
|
205
|
+
end
|
|
206
|
+
unless value.is_a?(String) && !value.empty? && value.bytesize <= MAX_CREDENTIAL_BYTES &&
|
|
207
|
+
!value.match?(/[\r\n\x00-\x1f\x7f]/)
|
|
208
|
+
raise Error, :invalid_credential
|
|
209
|
+
end
|
|
210
|
+
value
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def positive_number!(value)
|
|
214
|
+
return value if value.is_a?(Numeric) && value.finite? && value.positive?
|
|
215
|
+
|
|
216
|
+
raise Error, :invalid_timeout
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def positive_integer!(value)
|
|
220
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
221
|
+
|
|
222
|
+
raise Error, :invalid_limit
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Rails
|
|
5
|
+
class Client
|
|
6
|
+
# Public client-only exceptions. Never include response bodies, URLs,
|
|
7
|
+
# request metadata or credential strings in exception messages.
|
|
8
|
+
class Error < A2A::Rails::Error
|
|
9
|
+
attr_reader :reason, :operation
|
|
10
|
+
|
|
11
|
+
def initialize(reason, operation: nil)
|
|
12
|
+
@reason = reason
|
|
13
|
+
@operation = operation
|
|
14
|
+
super("Outbound A2A #{operation || 'Client'}: #{reason}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class ConfigurationError < Error; end
|
|
19
|
+
class InvalidInputError < Error; end
|
|
20
|
+
class DiscoveryError < Error; end
|
|
21
|
+
class UnsupportedInterfaceError < DiscoveryError; end
|
|
22
|
+
class TransportError < Error; end
|
|
23
|
+
|
|
24
|
+
class TimeoutError < TransportError
|
|
25
|
+
attr_reader :may_have_executed
|
|
26
|
+
|
|
27
|
+
def initialize(operation:, may_have_executed:)
|
|
28
|
+
@may_have_executed = may_have_executed
|
|
29
|
+
super(:timeout, operation: operation)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class AuthenticationError < Error
|
|
34
|
+
attr_reader :status
|
|
35
|
+
|
|
36
|
+
def initialize(status, operation:)
|
|
37
|
+
@status = status
|
|
38
|
+
super(:authentication_rejected, operation: operation)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class RemoteError < Error
|
|
43
|
+
attr_reader :code
|
|
44
|
+
|
|
45
|
+
def initialize(code, operation:)
|
|
46
|
+
@code = code
|
|
47
|
+
super(:remote_protocol_error, operation: operation)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class InvalidResponseError < Error; end
|
|
52
|
+
|
|
53
|
+
# Immutable plain-Ruby results, deliberately independent of the SDK.
|
|
54
|
+
SendResult = Struct.new(:kind, :task, :message, keyword_init: true)
|
|
55
|
+
ListResult = Struct.new(:tasks, :next_page_token, :page_size, :total_size, keyword_init: true)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|