a2a-rails 0.2.0 → 0.3.0.rc1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 447c947dcb0231daa7d00a4c0de158ca737795fcae5f529d71a0ed8d87f9ee3f
4
- data.tar.gz: bc9fa7e1884a169d45857a9febea788630d9917022e8919819357b4e57914d35
3
+ metadata.gz: 28e6e84db258492d965f909914a94906dc8de57c0e390eb36ce4749ababa4741
4
+ data.tar.gz: 421a68f93bb61827857f380fe7fed8a801a97acc2fbf9569297a782d9e6bb43b
5
5
  SHA512:
6
- metadata.gz: 6209ff98b146525320999d3d44e086053e6d767e1a20328436ff34908af222c87912c2e1b78745ce3b646499f1eb33c4e245d4b883fcc169870a1233604e4c10
7
- data.tar.gz: b0b74efa6e45bf7fe84ac63e1d5b2e68cc27386177f1fab728f0ea0582186adb55c7c3c379504b66b05997cc7600cb9e30056143df7311b8af2c591a8b73957d
6
+ metadata.gz: 849551ca60cf796328da07761d4c8fe5f7a5ebd5860028af88a1affd97775b491c1677ef963b96fb377504e6198e05260416a458f7e5821b137aeaa8e2eb62ff
7
+ data.tar.gz: 00b4174d36e073db0aa70a2bb3199c1edc59226b334197b506aaf29347f2168324e809a1290e9a5d8961e1e07e00a728e8ddc859099e4d140d364c9f17a6933e
data/CHANGELOG.md CHANGED
@@ -4,9 +4,53 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [0.2.0] - 2026-10-08 (stable candidate source; NOT published)
8
-
9
- > **Step 26-4 release preparation only.** Version `0.2.0` has been selected for an exact, source-built candidate. No `v0.2.0` Git tag, GitHub Release, or RubyGems push has been authorized. The published `0.2.0.rc2` pre-release is immutable and **does not include the subsequent fixes** below.
7
+ ## [0.3.0.rc1] - 2026-10-10 (pre-release candidate)
8
+
9
+ > **Pre-release candidate, not a stable-production security certification.**
10
+ > This candidate contains the previously unreleased outbound Client changes
11
+ > made after published stable `0.2.0`. The candidate source/gemspec VERSION
12
+ > is `0.3.0.rc1`. [Technical release gates](docs/release/step-29-5u-risk-based-oss-release-policy.md)
13
+ > require exact artifact verification, no known unresolved Critical/High
14
+ > vulnerabilities and an explicit owner GO for publication. An external
15
+ > security review is encouraged but **not a mandatory release condition**.
16
+ > Public A2A deployment security is tracked separately in [Issue #11](https://github.com/cuichangquan/a2a-rails/issues/11).
17
+
18
+ ### Added — outbound Client capabilities (v0.3.0.rc1)
19
+
20
+ - Outbound Rails `A2A::Rails::Client` for strict HTTPS Agent Card
21
+ discovery and A2A v1.0 JSON-RPC `SendMessage`, `GetTask`, `ListTasks`,
22
+ `CancelTask`, with separate Card/RPC credential callbacks, immutable
23
+ plain-Ruby results, direct Message or Task output and Client-only Rails mode.
24
+ - Explicit approved origin and public-IPv4 pinned socket policy, ordinary
25
+ hostname/SNI/CA verification, zero automatic transport retries, bounded
26
+ timeouts/request-response sizes and safe error categories. Host secrets,
27
+ tenant authorization and recovery are **not** implemented automatically.
28
+ - Structural validation of Message, nested Task/Artifact and File/Data Parts.
29
+ Native official Python and Go SDK local HTTPS interoperability, plus
30
+ genuine Solid Queue/Sidekiq separately executing outbound Client workers
31
+ on Rails 8.0/8.1, all tracked with scoped test limitations.
32
+
33
+ ### Security and compatibility notes
34
+
35
+ - Source-hardening tests cover DNS/SSRF, TLS/auth/log privacy, gzip and
36
+ malformed/chunked/truncated HTTP, JSON nesting, ambiguous remote
37
+ SendMessage/CancelTask timeout and nested response validation.
38
+ - **Go SDK follow-up:** Step 29-5q verified authenticated Go ListTasks pagination with two scoped test users and isolated Task visibility; anonymous callers still receive `-31401`.
39
+ - **Release evidence and limitations:** Exact-version source and Gem are
40
+ subject to mandatory build-once/SHA256, installed Ruby/Rails/PostgreSQL,
41
+ native Python/Go interoperability and security regression checks.
42
+ [Step 29-5u risk-based release policy](docs/release/step-29-5u-risk-based-oss-release-policy.md)
43
+ makes third-party security audit **optional** and does not assert a
44
+ certification. No known unresolved Critical/High code defect is acceptable
45
+ in release scope. Production host security review remains separate.
46
+ - Publishing a Gem and approving open-public **production deployment**
47
+ are distinct; a host must provide credential issuer verification,
48
+ business authorization, rate/concurrency and operational limits.
49
+ No release approval is implied by any CI PASS.
50
+
51
+ ## [0.2.0] - 2026-10-08 (published stable release)
52
+
53
+ > **Published in Step 27 after explicit approval.** [GitHub Release](https://github.com/cuichangquan/a2a-rails/releases/tag/v0.2.0) and [RubyGems 0.2.0](https://rubygems.org/gems/a2a-rails/versions/0.2.0) contain the exact verified artifact, with no rebuild. See the [publication record](docs/release/v0.2.0-publication-record.md) for its SHA256 and public-download verification. The immutable `0.2.0.rc2` pre-release **does not include the subsequent fixes** below.
10
54
 
11
55
  ### Added
12
56
 
data/README.md CHANGED
@@ -1,19 +1,22 @@
1
1
  # a2a-rails
2
- <img src="docs/assets/a2a-rails-handwritten-note.png">
2
+ <img src="docs/assets/a2a-rails-infographics.png">
3
3
  <br/>
4
- <img src="docs/assets/a2a-railsで広がるAI協働図鑑.png">
4
+ <br/>
5
+ <img src="docs/assets/a2a-rails-infographics-en.png">
5
6
 
6
7
  Rails-native integration for exposing Rails applications as A2A v1.0 agents.
7
8
 
8
- > **Release status (2026-10-08):** RubyGems has published **`0.1.0`** and the **`0.2.0.rc2` pre-release**. The current source is being verified as an **unpublished `0.2.0` stable candidate** (Step 26-4). In particular, the Rails/Zeitwerk and migration-generator fixes after rc2 are only in the newer source-built candidate. **No stable `0.2.0` tag, GitHub Release, or RubyGems publication has been authorized.**
9
+ > **Release status (2026-10-08):** **`0.2.0` is published as a stable release** on [RubyGems](https://rubygems.org/gems/a2a-rails/versions/0.2.0) and [GitHub](https://github.com/cuichangquan/a2a-rails/releases/tag/v0.2.0). It includes the Rails/Zeitwerk and migration-generator fixes after rc2. The public Gem matches the verified artifact byte-for-byte; see the [publication record](docs/release/v0.2.0-publication-record.md).
9
10
 
10
11
  - RubyGems: https://rubygems.org/gems/a2a-rails
11
- - Published pre-release: https://github.com/cuichangquan/a2a-rails/releases/tag/v0.2.0-rc.2 (stable `0.2.0` not yet released)
12
+ - Published stable release: https://github.com/cuichangquan/a2a-rails/releases/tag/v0.2.0
12
13
  - Previous stable release: https://github.com/cuichangquan/a2a-rails/releases/tag/v0.1.0
13
14
  - Changelog: [CHANGELOG.md](CHANGELOG.md)
14
- - Release record: [docs/release/v0.1.0-record.md](docs/release/v0.1.0-record.md)
15
- - **Release readiness:** [current release/deployment checklist](docs/release/security-hardening-release-checklist.md) · [v0.1.0 → proposed v0.2 upgrade guide](docs/release/upgrading-v0.1.0-to-v0.2.md). The old rc1 record is historical evidence only.
16
- - **Roadmap / 次にやること:** [ROADMAP.md](ROADMAP.md) — [Step 26 / Issue #62](https://github.com/cuichangquan/a2a-rails/issues/62) tracks the `0.2.0` stable candidate and release gates; [production Issue #11](https://github.com/cuichangquan/a2a-rails/issues/11) remains open. See [stable-readiness record](docs/release/v0.2.0-stable-readiness.md).
15
+ - Release record: [v0.2.0 publication record](docs/release/v0.2.0-publication-record.md)
16
+ - **Security policy / 脆弱性報告:** [SECURITY.md](SECURITY.md) — confidential vulnerability reporting, current assurance boundaries and production responsibilities.
17
+ - **v0.3.x prerelease policy:** [Risk-based release gates](docs/release/step-29-5u-risk-based-oss-release-policy.md) — independently commissioned external audit is **recommended but optional**, not a known vulnerability or RubyGems requirement. Technical security checks, no known unresolved Critical/High findings, exact Gem provenance and explicit maintainer release approval remain mandatory; [optional community review #113](https://github.com/cuichangquan/a2a-rails/issues/113). **No v0.3.x has been published.**
18
+ - **Release readiness:** [current release/deployment checklist](docs/release/security-hardening-release-checklist.md) · [v0.1.0 → v0.2.0 upgrade guide](docs/release/upgrading-v0.1.0-to-v0.2.md). The old rc1 record is historical evidence only.
19
+ - **Roadmap / 次にやること:** [ROADMAP.md](ROADMAP.md) — [Step 26 / Issue #62](https://github.com/cuichangquan/a2a-rails/issues/62) records the `0.2.0` stable release gates; [production Issue #11](https://github.com/cuichangquan/a2a-rails/issues/11) remains open. See [stable-readiness record](docs/release/v0.2.0-stable-readiness.md).
17
20
  - **Official A2A TCK results:** [Pinned JSON-RPC MUST report and reproduction](docs/testing/official-a2a-tck.md) — after Step 17-4: **63 passed / 1 failed / 171 skipped / 30 deselected** (pytest). The remaining `CORE-SEND-003` mismatch is tracked [upstream in #202](https://github.com/a2aproject/a2a-tck/issues/202). The TCK workflow is informational, **not** an A2A conformance certificate.
18
21
 
19
22
  - [A2Aの全体像(日本語・A4 1枚PDF)](docs/guides/a2a-protocol-overview-ja.pdf) — 登場人物・依頼の流れ・主要用語・MCPとの違いをまとめた学習資料。
@@ -23,10 +26,10 @@ Rails-native integration for exposing Rails applications as A2A v1.0 agents.
23
26
 
24
27
  **[a2a-rails-demo — independent Rails 8 Echo Agent](https://github.com/cuichangquan/a2a-rails-demo)**
25
28
 
26
- Run a complete standalone Rails Agent with the current unreleased `a2a-rails` source pinned by Git commit (the published RubyGems v0.1.0 does not contain direct-Message support). The separate Demo covers Agent Card discovery, JSON-RPC v1.0 SendMessage **Task and direct Message**, GetTask, ListTasks and error handling.
29
+ Run a complete standalone Rails Agent using the **published RubyGems `a2a-rails = 0.2.0`** (not a Git source checkout). The separate Demo covers Agent Card discovery, JSON-RPC v1.0 SendMessage **Task and direct Message**, GetTask, ListTasks and error handling.
27
30
 
28
31
  - [Demo Quick Start, code and example curl requests](https://github.com/cuichangquan/a2a-rails-demo#quick-start).
29
- - [Demo GitHub Actions smoke](https://github.com/cuichangquan/a2a-rails-demo/actions/workflows/smoke.yml) — independent real HTTP tests **16/16 checks passed** ([PR #1](https://github.com/cuichangquan/a2a-rails-demo/pull/1)).
32
+ - [Demo GitHub Actions smoke](https://github.com/cuichangquan/a2a-rails-demo/actions/workflows/smoke.yml) — independent Rails 8.1.0 / Ruby 3.4.10 HTTP smoke **16/16 checks passed** against published Gem `0.2.0` ([Step 28 Demo PR #2, merged](https://github.com/cuichangquan/a2a-rails-demo/pull/2), [passing PR CI](https://github.com/cuichangquan/a2a-rails-demo/actions/runs/37736426834)). The CI also confirms `A2A::Rails::VERSION == "0.2.0"`, RubyGems as the dependency source, and the demo's production-startup refusal.
30
33
  - **Local-only development/test** demo: no trusted verifier or durable Task store, **not** an internet-facing production template. See [production security](docs/guides/production-security.md).
31
34
 
32
35
  ## What is a2a-rails?
@@ -50,9 +53,9 @@ The Gem provides:
50
53
  - a Rails-native Agent / Skill DSL;
51
54
  - A2A Agent Card generation;
52
55
  - automatically mounted A2A HTTP endpoints;
53
- - synchronous Task execution by default, plus opt-in ActiveJob-backed async Task execution on unreleased `main`;
56
+ - synchronous Task execution by default, plus opt-in ActiveJob-backed async Task execution in published `0.2.0`;
54
57
  - SDK-independent Handler inputs;
55
- - a process-local MemoryStore by default, plus an optional durable ActiveRecordStore on unreleased main;
58
+ - a process-local MemoryStore by default, plus an optional durable ActiveRecordStore in `0.2.0`;
56
59
  - Rails generators for initial setup;
57
60
  - an internal Protocol Adapter boundary around the upstream SDK.
58
61
 
@@ -270,17 +273,17 @@ POST /a2a
270
273
 
271
274
  The Rails Engine is mounted automatically.
272
275
 
273
- ### Security work on main (unreleased; not in published v0.1.0)
276
+ ### Security in v0.2.0 (not in v0.1.0)
274
277
 
275
- The unreleased `main` branch includes a host-provided `config.authenticate_request` callback for `POST /a2a`, plus per-principal Task ownership checks. Without an authenticator, production and other non-development/test environments fail closed; the local development/test Quick Start remains available. See [Authentication guide](docs/guides/authentication.md).
278
+ Published `0.2.0` includes a host-provided `config.authenticate_request` callback for `POST /a2a`, plus per-principal Task ownership checks. Without an authenticator, production and other non-development/test environments fail closed; the local development/test Quick Start remains available. See [Authentication guide](docs/guides/authentication.md).
276
279
 
277
- > **Important:** These protections are **absent from RubyGems 0.1.0** but included in the published `0.2.0.rc2` pre-release; the stable candidate has additional fixes. Neither pre-release nor stable source automatically makes a deployed endpoint production-secure. Distributed rate limits, business authorization and the deployment review remain tracked in [Issue #11](https://github.com/cuichangquan/a2a-rails/issues/11).
280
+ > **Important:** These protections are **absent from RubyGems 0.1.0** but included in the published `0.2.0.rc2` pre-release; stable `0.2.0` includes additional fixes. Neither pre-release nor stable source automatically makes a deployed endpoint production-secure. Distributed rate limits, business authorization and the deployment review remain tracked in [Issue #11](https://github.com/cuichangquan/a2a-rails/issues/11).
278
281
 
279
- ### Step 16-4 request hardening (merged on main; unreleased)
282
+ ### Step 16-4 request hardening (included in v0.2.0)
280
283
 
281
- The unreleased main branch adds a bounded JSON-RPC body (`config.max_request_bytes`, default 1 MiB), Content-Type validation, stricter parameter checks, a pagination snapshot cap, and reduced exception logging. See [Request Hardening Guide](docs/guides/request-hardening.md).
284
+ Published `0.2.0` includes a bounded JSON-RPC body (`config.max_request_bytes`, default 1 MiB), Content-Type validation, stricter parameter checks, a pagination snapshot cap, and reduced exception logging. See [Request Hardening Guide](docs/guides/request-hardening.md).
282
285
 
283
- Rate limiting, application-specific authorization and production deployment safeguards remain the host's responsibility or future work. Step 16-5 adds explicit A2A Agent Card Bearer authentication advertisement on unreleased `main`; it must match the host verifier. **These improvements are not included in the published v0.1.0 Gem; see the later rc2 pre-release and current stable candidate.**
286
+ Rate limiting, application-specific authorization and production deployment safeguards remain the host's responsibility or future work. Step 16-5 adds explicit A2A Agent Card Bearer authentication advertisement in published `0.2.0`; it must match the host verifier. **These improvements are not included in the published v0.1.0 Gem; see the published stable `0.2.0` release.**
284
287
 
285
288
  ### Production deployment review (Step 16-6)
286
289
 
@@ -337,14 +340,14 @@ Hash / Array → Data Part
337
340
  nil → no Artifact
338
341
  other object → ArtifactMappingError
339
342
 
340
- Unreleased Step 17-3 (not in RubyGems v0.1.0):
343
+ Included since v0.2.0 (not in v0.1.0):
341
344
  A2A::Rails::FileArtifact.bytes(...) → File Part with base64 raw bytes
342
345
  A2A::Rails::FileArtifact.url(...) → File Part with an HTTPS URL
343
346
  ```
344
347
 
345
- **File Artifact output is unreleased.** Rails Handlers can explicitly return a `FileArtifact.bytes(data:, filename:, media_type:)` or `FileArtifact.url(url:, filename:, media_type:)`. See the [File Artifact output guide](docs/guides/file-artifacts.md). Input file Parts, downloading remote URLs and production file authorization are not supplied by the Gem.
348
+ **File Artifact output is included in v0.2.0.** Rails Handlers can explicitly return a `FileArtifact.bytes(data:, filename:, media_type:)` or `FileArtifact.url(url:, filename:, media_type:)`. See the [File Artifact output guide](docs/guides/file-artifacts.md). Input file Parts, downloading remote URLs and production file authorization are not supplied by the Gem.
346
349
 
347
- **Unreleased Step 17-4:** A2A v1.0 also permits a direct `Message` from `SendMessage`. The host Agent can opt into `response_mode :message` or select the mode using a callable; the default remains `:task`. Direct replies do **not** create Task records. See the [Direct Message response guide](docs/guides/direct-message-responses.md) for the contract and safety implications.
350
+ **Included in v0.2.0 (Step 17-4):** A2A v1.0 also permits a direct `Message` from `SendMessage`. The host Agent can opt into `response_mode :message` or select the mode using a callable; the default remains `:task`. Direct replies do **not** create Task records. See the [Direct Message response guide](docs/guides/direct-message-responses.md) for the contract and safety implications.
348
351
 
349
352
  Supported Task operations:
350
353
 
@@ -357,9 +360,9 @@ Supported Task operations:
357
360
 
358
361
  The default `Task::MemoryStore` is thread-safe but process-local. Tasks and pagination cursors are not durable across process restarts and are not shared between processes.
359
362
 
360
- **Unreleased Step 21:** applications that need durable, multi-worker Task state can opt into `config.task_store = :active_record`. The ActiveRecordStore uses owner-scoped SQL access, row-locked transitions, signed keyset cursors, terminal retention, bounded pruning and maintenance limits. See [ActiveRecord Task Store](docs/guides/active-record-task-store.md). PostgreSQL 16 persistence/locking smoke is verified in CI.
363
+ **Included in v0.2.0 (Step 21):** applications that need durable, multi-worker Task state can opt into `config.task_store = :active_record`. The ActiveRecordStore uses owner-scoped SQL access, row-locked transitions, signed keyset cursors, terminal retention, bounded pruning and maintenance limits. See [ActiveRecord Task Store](docs/guides/active-record-task-store.md). PostgreSQL 16 persistence/locking smoke is verified in CI.
361
364
 
362
- **Unreleased Step 22:** Task execution remains synchronous by default. Hosts can opt into ActiveJob-backed async execution globally, per Agent, or per Skill; precedence is **Skill > Agent > global**.
365
+ **Included in v0.2.0 (Step 22):** Task execution remains synchronous by default. Hosts can opt into ActiveJob-backed async execution globally, per Agent, or per Skill; precedence is **Skill > Agent > global**.
363
366
 
364
367
  ```ruby
365
368
  A2A::Rails.configure do |config|
@@ -485,7 +488,7 @@ Artifact: Echo: Hello
485
488
 
486
489
  See [docs/release/v0.1.0-record.md](docs/release/v0.1.0-record.md) for the complete release evidence.
487
490
 
488
- ## Cross-language A2A interoperability — unreleased main source
491
+ ## Cross-language A2A interoperability — source verification
489
492
 
490
493
  [Step 18 / PR #27](https://github.com/cuichangquan/a2a-rails/pull/27) verified the **official** Python `a2a-sdk==1.2.2` and Go `a2a-go/v2 v2.6.0` clients against a loopback-only Rails JSON-RPC A2A v1.0 Agent. Both discovered Agent Cards, decoded Task and direct Message responses, queried Tasks and checked terminal cancellation and version errors.
491
494
 
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pinned_https_transport"
4
+
5
+ module A2A
6
+ module Rails
7
+ class Client
8
+ # Internal A2A v1.0 JSON-RPC Agent Card discovery and RPC boundary.
9
+ #
10
+ # NOT a public Client facade: application authentication, DTO contracts,
11
+ # complete schema validation and multi-language HTTP integration follow
12
+ # in later Step 29 PRs. Default construction always uses the secure
13
+ # OutboundPolicy and PinnedHttpsTransport; injection is test-only.
14
+ class AgentCardResolver
15
+ class Error < A2A::Rails::Error
16
+ attr_reader :reason
17
+
18
+ def initialize(reason)
19
+ @reason = reason
20
+ super("Outbound A2A Agent Card rejected: #{reason}")
21
+ end
22
+ end
23
+
24
+ class InvalidCard < Error; end
25
+ class UnsupportedInterface < Error; end
26
+ class InvalidRPC < Error; end
27
+
28
+ class RemoteError < Error
29
+ attr_reader :code
30
+
31
+ def initialize(code)
32
+ @code = code
33
+ super(:remote_json_rpc_error)
34
+ end
35
+ end
36
+
37
+ Interface = Struct.new(:url, :tenant, keyword_init: true)
38
+ Discovery = Struct.new(:card, :interface, keyword_init: true)
39
+
40
+ JSONRPC_BINDING = "JSONRPC"
41
+ PROTOCOL_VERSION = "1.0"
42
+ METHODS = %w[SendMessage GetTask ListTasks CancelTask].freeze
43
+ MAX_INTERFACES = 64
44
+ MAX_SKILLS = 256
45
+
46
+ def initialize(agent_card_url:, allowed_origins: nil,
47
+ card_authorization: nil, card_credential_origin: nil,
48
+ authorization: nil, credential_origin: nil,
49
+ policy: nil, transport: nil)
50
+ # Production callers must construct through the forthcoming public
51
+ # Client facade, which will not expose policy/transport overrides.
52
+ @policy = policy || OutboundPolicy.new(allowed_origins: allowed_origins)
53
+ @transport = transport || PinnedHttpsTransport.new(policy: @policy)
54
+ @agent_card_url = agent_card_url
55
+ @card_authorization = card_authorization
56
+ @card_credential_origin = card_credential_origin
57
+ @authorization = authorization
58
+ @credential_origin = credential_origin
59
+ end
60
+
61
+ # Re-fetch on each invocation; never share card/identity cache.
62
+ # The transport separately resolves and pins the card destination.
63
+ def discover
64
+ response = @transport.get_json(
65
+ url: @agent_card_url,
66
+ authorization: @card_authorization,
67
+ credential_origin: @card_credential_origin
68
+ )
69
+ card = response.json
70
+ validate_card!(card)
71
+ interface = choose_interface!(card.fetch("supportedInterfaces"))
72
+ Discovery.new(card: deep_freeze(card), interface: interface).freeze
73
+ end
74
+
75
+ # A thin INTERNAL wire operation, not the public SendMessage API.
76
+ # The supplied params use JSON/protocol camelCase, not Ruby DTO keys.
77
+ def rpc(method:, params:, id:)
78
+ unless METHODS.include?(method) && params.is_a?(Hash) &&
79
+ params.keys.all? { |key| key.is_a?(String) } &&
80
+ !params.key?("tenant") &&
81
+ (id.is_a?(String) && !id.empty? || id.is_a?(Integer))
82
+ raise InvalidRPC, :invalid_parameters
83
+ end
84
+
85
+ interface = discover.interface
86
+ request_params = params.dup
87
+ # A2A 1.0 puts the opaque tenant in the request's params, not the
88
+ # JSON-RPC envelope. The host is forbidden from overriding it.
89
+ request_params["tenant"] = interface.tenant unless interface.tenant.nil?
90
+ wire = {
91
+ "jsonrpc" => "2.0",
92
+ "id" => id,
93
+ "method" => method,
94
+ "params" => request_params
95
+ }
96
+ response = @transport.post_json(
97
+ url: interface.url, json: wire,
98
+ authorization: @authorization,
99
+ credential_origin: @credential_origin
100
+ )
101
+ envelope = response.json
102
+ unless envelope.is_a?(Hash) && envelope["jsonrpc"] == "2.0" &&
103
+ envelope["id"] == id &&
104
+ (envelope.key?("result") ^ envelope.key?("error"))
105
+ raise InvalidRPC, :invalid_envelope
106
+ end
107
+ if envelope.key?("error")
108
+ error = envelope["error"]
109
+ raise InvalidRPC, :invalid_envelope unless error.is_a?(Hash) && error["code"].is_a?(Integer)
110
+
111
+ # The remote error.message/data are untrusted, and may contain
112
+ # secrets. Never reflect them in exception messages or logs.
113
+ raise RemoteError, error.fetch("code")
114
+ end
115
+
116
+ raise InvalidRPC, :invalid_result unless envelope["result"].is_a?(Hash)
117
+
118
+ deep_freeze(envelope.fetch("result"))
119
+ end
120
+
121
+ private
122
+
123
+ def validate_card!(card)
124
+ unless card.is_a?(Hash) &&
125
+ %w[name description version].all? { |key| present_text?(card[key]) } &&
126
+ card["capabilities"].is_a?(Hash) &&
127
+ valid_mode_list?(card["defaultInputModes"]) &&
128
+ valid_mode_list?(card["defaultOutputModes"]) &&
129
+ card["skills"].is_a?(Array) && !card["skills"].empty? &&
130
+ card["skills"].size <= MAX_SKILLS &&
131
+ card["skills"].all? { |s| s.is_a?(Hash) && present_text?(s["id"]) } &&
132
+ card["supportedInterfaces"].is_a?(Array) &&
133
+ !card["supportedInterfaces"].empty? &&
134
+ card["supportedInterfaces"].size <= MAX_INTERFACES
135
+ raise InvalidCard, :invalid_card_shape
136
+ end
137
+
138
+ card.fetch("supportedInterfaces").each do |item|
139
+ unless item.is_a?(Hash) &&
140
+ present_text?(item["url"]) &&
141
+ present_text?(item["protocolBinding"]) &&
142
+ present_text?(item["protocolVersion"])
143
+ raise InvalidCard, :invalid_interface_shape
144
+ end
145
+ end
146
+ end
147
+
148
+ def choose_interface!(interfaces)
149
+ candidate = interfaces.find do |entry|
150
+ entry["protocolBinding"] == JSONRPC_BINDING &&
151
+ entry["protocolVersion"] == PROTOCOL_VERSION
152
+ end
153
+ raise UnsupportedInterface, :no_jsonrpc_v1_interface unless candidate
154
+
155
+ if candidate.key?("tenant") &&
156
+ (!candidate["tenant"].is_a?(String) || candidate["tenant"].bytesize > 512)
157
+ raise InvalidCard, :invalid_tenant
158
+ end
159
+
160
+ # Validate the UNTRUSTED card's selected endpoint independently of
161
+ # card origin; no guessed path, origin fallback or automatic retry.
162
+ target = @policy.resolve!(candidate.fetch("url"))
163
+ Interface.new(url: target.url, tenant: candidate["tenant"]&.dup&.freeze).freeze
164
+ end
165
+
166
+ def valid_mode_list?(value)
167
+ value.is_a?(Array) && !value.empty? &&
168
+ value.all? { |element| present_text?(element) }
169
+ end
170
+
171
+ def present_text?(value)
172
+ value.is_a?(String) && !value.strip.empty?
173
+ end
174
+
175
+ def deep_freeze(value)
176
+ case value
177
+ when Hash
178
+ value.each do |key, element|
179
+ deep_freeze(key)
180
+ deep_freeze(element)
181
+ end
182
+ when Array
183
+ value.each { |element| deep_freeze(element) }
184
+ end
185
+ value.freeze
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ class Client
6
+ # Explicit A2A protocol keys only. Arbitrary JSON metadata/data and
7
+ # unknown extension fields are opaque and must NOT have keys rewritten.
8
+ module Codec
9
+ KEYS = {
10
+ "message_id" => "messageId",
11
+ "context_id" => "contextId",
12
+ "task_id" => "taskId",
13
+ "artifact_id" => "artifactId",
14
+ "media_type" => "mediaType",
15
+ "mime_type" => "mimeType",
16
+ "file_name" => "fileName",
17
+ "supported_interfaces" => "supportedInterfaces",
18
+ "protocol_binding" => "protocolBinding",
19
+ "protocol_version" => "protocolVersion",
20
+ "default_input_modes" => "defaultInputModes",
21
+ "default_output_modes" => "defaultOutputModes",
22
+ "input_modes" => "inputModes",
23
+ "output_modes" => "outputModes",
24
+ "accepted_output_modes" => "acceptedOutputModes",
25
+ "return_immediately" => "returnImmediately",
26
+ "history_length" => "historyLength",
27
+ "page_size" => "pageSize",
28
+ "page_token" => "pageToken",
29
+ "next_page_token" => "nextPageToken",
30
+ "total_size" => "totalSize",
31
+ "status_timestamp_after" => "statusTimestampAfter",
32
+ "include_artifacts" => "includeArtifacts",
33
+ "security_schemes" => "securitySchemes",
34
+ "security_requirements" => "securityRequirements",
35
+ "extended_agent_card" => "extendedAgentCard",
36
+ "push_notifications" => "pushNotifications"
37
+ }.freeze
38
+ REVERSE = KEYS.invert.freeze
39
+ OPAQUE = %w[data metadata extensions securitySchemes securityRequirements].freeze
40
+
41
+ module_function
42
+
43
+ def encode(value, opaque: false)
44
+ case value
45
+ when Hash
46
+ convert_hash(value, :encode, opaque: opaque)
47
+ when Array
48
+ value.map { |item| encode(item, opaque: opaque) }
49
+ else
50
+ value
51
+ end
52
+ end
53
+
54
+ def decode(value, opaque: false)
55
+ case value
56
+ when Hash
57
+ convert_hash(value, :decode, opaque: opaque)
58
+ when Array
59
+ value.map { |item| decode(item, opaque: opaque) }.freeze
60
+ when String
61
+ value.dup.freeze
62
+ else
63
+ value
64
+ end
65
+ end
66
+
67
+ def convert_hash(value, direction, opaque:)
68
+ result = {}
69
+ value.each do |raw_key, entry|
70
+ unless raw_key.is_a?(String) || raw_key.is_a?(Symbol)
71
+ raise InvalidInputError, :invalid_hash_key
72
+ end
73
+ key = raw_key.to_s
74
+ if direction == :encode
75
+ output = opaque ? key : KEYS.fetch(key, key)
76
+ else
77
+ output = !opaque && REVERSE.key?(key) ? REVERSE.fetch(key).to_sym :
78
+ (!opaque && KNOWN_KEYS.include?(key) ? key.to_sym : key)
79
+ end
80
+ raise InvalidInputError, :ambiguous_protocol_key if result.key?(output)
81
+ child_opaque = opaque || OPAQUE.include?(key) || OPAQUE.include?(output.to_s)
82
+ result[output] = if direction == :encode
83
+ encode(entry, opaque: child_opaque)
84
+ else
85
+ decode(entry, opaque: child_opaque)
86
+ end
87
+ end
88
+ direction == :decode ? result.freeze : result
89
+ end
90
+
91
+ KNOWN_KEYS = %w[
92
+ id name description version skills capabilities streaming
93
+ role parts text raw url data filename mimeType mediaType
94
+ status state timestamp artifacts history message task tasks extensions
95
+ tenant contextId messageId taskId artifactId
96
+ supportedInterfaces protocolBinding protocolVersion
97
+ defaultInputModes defaultOutputModes inputModes outputModes
98
+ acceptedOutputModes returnImmediately historyLength
99
+ pageSize pageToken nextPageToken totalSize
100
+ statusTimestampAfter includeArtifacts metadata
101
+ securitySchemes securityRequirements
102
+ ].freeze
103
+ end
104
+ end
105
+ end
106
+ end