@openwop/openwop-conformance 2.45.30 → 2.46.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.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -2
- package/coverage.md +1 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +68 -6
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +14 -14
- package/src/lib/negotiation-record.ts +108 -0
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +33 -4
- package/src/scenarios/v2-negotiation-decided-inbound.test.ts +155 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.46.0] — 2026-10-10 — RFC 0242: a negotiation record says what was asked for, and an inbound negotiation has a home
|
|
4
|
+
|
|
5
|
+
A minor, not a patch: `host-event.schema.json` gains a cross-file `$ref` to `run-event-payloads.schema.json#/$defs/negotiationDecided` (COMPATIBILITY.md §2.1). A validator that pre-registers a fixed list of peer schemas must register `run-event-payloads.schema.json` before compiling `host-event.schema.json`.
|
|
6
|
+
|
|
7
|
+
- **`requested` on `negotiation.decided` (RFC 0242 §A).** `v2-negotiation-decided-emitted` gains two legs, `0242.requested.outbound` and `.mcp`: the record's `requested` equals the version the host's client named to the suite's peer, read from what the peer received. A downgrade is now visible as an `accepted` record whose `version` differs from `requested`.
|
|
8
|
+
- **New `v2-negotiation-decided-inbound` (RFC 0242 §B).** Gated on an inbound A2A or MCP surface (`a2a.agentCardUrl`, `mcp.serverUrls`, `mcp.serverMount`), with no seam:
|
|
9
|
+
- `0242.inbound.listed`: such a host lists `negotiation.decided` as a durable `hostEvents` type.
|
|
10
|
+
- `0242.inbound.recorded` / `.refused` (and `.mcp`): with `/host/events` open, an authenticated inbound exchange naming `preferredVersion`, then one naming a version below the floor, each leaves a durable, runless `negotiation.decided` host event with the right `outcome` and `requested`.
|
|
11
|
+
- Proven against the v2 reference host with the §B implementation: every leg passes, and every record leg fails when the host drops `requested`. On a host that serves A2A or MCP inbound without listing the type, `listed` fails and the record legs are `inapplicable`, naming it.
|
|
12
|
+
- **New lib `negotiation-record.ts`** with self-tests: `askedVersion`, `judgeRequested`, `judgeListed`, `judgeInboundRecord`.
|
|
13
|
+
- **What a host's next bundle shows:** a host that serves A2A or MCP inbound records `0242.inbound.listed` `executed-fail` until it lists the type; the v2 reference host and openwop-app follow (RFC 0242 G1). A host that advertises neither surface is unaffected.
|
|
14
|
+
|
|
3
15
|
## [2.45.30] — 2026-10-09 — the seams profile floor is seam-driven
|
|
4
16
|
|
|
5
17
|
- **`openwop-conformance-seams-v2` no longer certifies on a host that mounts no seams.** Its predicate is a `conformance` block at the discovery root, deliberately necessary and not sufficient: on a host that does not advertise `conformance.seamsProfile`, every floor leg records `inapplicable`, so the profile is claimed with `witnessCount 0` and not certified. But a floor row is recorded per scenario file, and two floor files had a leg that runs without a seam. openwop-app's production bundle `4683d9f89` (suite 2.45.28) carries a `conformance` block holding only `certificationBundleUrl`, and certified the profile on the facet leg alone.
|
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ Each scenario file records its disposition and assertion count in `<report-dir>/
|
|
|
137
137
|
|
|
138
138
|
## What's Covered
|
|
139
139
|
|
|
140
|
-
The current suite has
|
|
140
|
+
The current suite has 653 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 213 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
|
|
141
141
|
|
|
142
142
|
The v2 scenarios (`src/scenarios/v2-*.test.ts`) cover, by area:
|
|
143
143
|
|
|
@@ -166,7 +166,7 @@ Three kinds of scenario share the tree:
|
|
|
166
166
|
- **Host** checks drive a live host over HTTP.
|
|
167
167
|
- **Gated** checks run only when the host advertises the family, profile or fixture they need, and otherwise record `inapplicable` or `blocked` with a reason. `--require-behavior` turns an advertised-but-unwitnessable behaviour into a failure.
|
|
168
168
|
|
|
169
|
-
Current source tree:
|
|
169
|
+
Current source tree: 653 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
|
|
170
170
|
|
|
171
171
|
## How the 2.x suite is built
|
|
172
172
|
|
package/coverage.md
CHANGED
|
@@ -30,7 +30,7 @@ Each scenario that targets major 2 is listed once, under the [`spec/v2/core/`](.
|
|
|
30
30
|
| [`i18n.md`](../spec/v2/core/i18n.md) | `v2-content-locale-keys.test.ts`, `v2-i18n-negotiation.test.ts`, `v2-localized-content-delivery.test.ts` |
|
|
31
31
|
| [`idempotency.md`](../spec/v2/core/idempotency.md) | `v2-durability-recovery.test.ts`, `v2-idempotency-in-flight.test.ts`, `v2-idempotency-key-grammar.test.ts` |
|
|
32
32
|
| [`identity.md`](../spec/v2/core/identity.md) | `auth-challenge-no-oracle.test.ts`, `v2-assurance-downgrade-audited.test.ts`, `v2-auth-challenge.test.ts`, `v2-authorization-advertisement.test.ts`, `v2-bound-id-kinds.test.ts`, `v2-bound-id-path-projection.test.ts`, `v2-id-grammar.test.ts`, `v2-interrupt-token-scheme.test.ts`, `v2-lane-exp-only-bound.test.ts`, `v2-lane-issuer-advertised.test.ts`, `v2-oidc-id-token-audience.test.ts`, `v2-owner-subject-required.test.ts`, `v2-protected-resource-metadata.test.ts`, `v2-revocation-honored.test.ts`, `v2-subject-link-record.test.ts` |
|
|
33
|
-
| [`interop.md`](../spec/v2/core/interop.md) | `otel-mcp-semconv-projection.test.ts`, `v2-a2a-agent-cards.test.ts`, `v2-a2a-client-error-details.test.ts`, `v2-a2a-operation-map.test.ts`, `v2-a2a-push-delivery.test.ts`, `v2-interop-trace-context.test.ts`, `v2-legacy-profiles-absent.test.ts`, `v2-mcp-mount-map.test.ts`, `v2-mcp-tasks.test.ts`, `v2-minimum-version-refused.test.ts`, `v2-mrtr-rounds-ceiling.test.ts`, `v2-negotiation-authenticated.test.ts`, `v2-negotiation-decided-emitted.test.ts`, `v2-no-transport-advertisement.test.ts`, `v2-refresh-sla.test.ts` |
|
|
33
|
+
| [`interop.md`](../spec/v2/core/interop.md) | `otel-mcp-semconv-projection.test.ts`, `v2-a2a-agent-cards.test.ts`, `v2-a2a-client-error-details.test.ts`, `v2-a2a-operation-map.test.ts`, `v2-a2a-push-delivery.test.ts`, `v2-interop-trace-context.test.ts`, `v2-legacy-profiles-absent.test.ts`, `v2-mcp-mount-map.test.ts`, `v2-mcp-tasks.test.ts`, `v2-minimum-version-refused.test.ts`, `v2-mrtr-rounds-ceiling.test.ts`, `v2-negotiation-authenticated.test.ts`, `v2-negotiation-decided-emitted.test.ts`, `v2-negotiation-decided-inbound.test.ts`, `v2-no-transport-advertisement.test.ts`, `v2-refresh-sla.test.ts` |
|
|
34
34
|
| [`interrupt.md`](../spec/v2/core/interrupt.md) | `v2-approval-reject-disposition.test.ts`, `v2-approver-enforced.test.ts`, `v2-callback-url-guarded.test.ts` |
|
|
35
35
|
| [`node-pack-runtimes.md`](../spec/v2/core/node-pack-runtimes.md) | `v2-node-pack-runtimes-advertisement.test.ts`, `v2-wasm-memory-cap.test.ts`, `v2-wasm-abi-reject.test.ts` |
|
|
36
36
|
| [`oauth.md`](../spec/v2/core/oauth.md) | `v2-credential-interrupt.test.ts`, `v2-credentials-advertisement.test.ts`, `v2-oauth-client-pkce-state-iss.test.ts`, `v2-oauth-mcp-reach-discovery.test.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.46.0",
|
|
4
4
|
"description": "Black-box conformance suite for OpenWOP hosts (v2 current; v1 scenarios retained for the frozen v1 tree).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.
|
|
59
|
+
"@openwop/spec-artifacts": "2.46.0"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 741,
|
|
6
|
+
"tests": 2790,
|
|
7
|
+
"withStableId": 2790,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2646
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -33579,7 +33579,7 @@
|
|
|
33579
33579
|
{
|
|
33580
33580
|
"id": "openwop.it.v2-negotiation-decided-emitted.an-a2a-exchange-leaves-negotiation-decided-on-the-host-log",
|
|
33581
33581
|
"file": "v2-negotiation-decided-emitted.test.ts",
|
|
33582
|
-
"line":
|
|
33582
|
+
"line": 166,
|
|
33583
33583
|
"title": "an A2A exchange leaves negotiation.decided on the host log",
|
|
33584
33584
|
"explicitId": null,
|
|
33585
33585
|
"citations": []
|
|
@@ -33587,11 +33587,73 @@
|
|
|
33587
33587
|
{
|
|
33588
33588
|
"id": "openwop.it.v2-negotiation-decided-emitted.an-mcp-exchange-leaves-negotiation-decided-on-the-host-log",
|
|
33589
33589
|
"file": "v2-negotiation-decided-emitted.test.ts",
|
|
33590
|
-
"line":
|
|
33590
|
+
"line": 169,
|
|
33591
33591
|
"title": "an MCP exchange leaves negotiation.decided on the host log",
|
|
33592
33592
|
"explicitId": null,
|
|
33593
33593
|
"citations": []
|
|
33594
33594
|
},
|
|
33595
|
+
{
|
|
33596
|
+
"id": "openwop.it.v2-negotiation-decided-emitted.an-a2a-record-names-the-version-the-host-asked-for-rfc-0242-a-2",
|
|
33597
|
+
"file": "v2-negotiation-decided-emitted.test.ts",
|
|
33598
|
+
"line": 172,
|
|
33599
|
+
"title": "an A2A record names the version the host asked for (RFC 0242 §A.2)",
|
|
33600
|
+
"explicitId": null,
|
|
33601
|
+
"citations": []
|
|
33602
|
+
},
|
|
33603
|
+
{
|
|
33604
|
+
"id": "openwop.it.v2-negotiation-decided-emitted.an-mcp-record-names-the-revision-the-host-asked-for-rfc-0242-a-2",
|
|
33605
|
+
"file": "v2-negotiation-decided-emitted.test.ts",
|
|
33606
|
+
"line": 175,
|
|
33607
|
+
"title": "an MCP record names the revision the host asked for (RFC 0242 §A.2)",
|
|
33608
|
+
"explicitId": null,
|
|
33609
|
+
"citations": []
|
|
33610
|
+
},
|
|
33611
|
+
{
|
|
33612
|
+
"id": "openwop.it.v2-negotiation-decided-inbound.a-host-serving-a2a-or-mcp-inbound-lists-negotiation-decided-as-a-durable-host-ev",
|
|
33613
|
+
"file": "v2-negotiation-decided-inbound.test.ts",
|
|
33614
|
+
"line": 134,
|
|
33615
|
+
"title": "a host serving A2A or MCP inbound lists negotiation.decided as a durable host-event type",
|
|
33616
|
+
"explicitId": "openwop.requirement.0242.inbound.listed",
|
|
33617
|
+
"citations": [
|
|
33618
|
+
{
|
|
33619
|
+
"section": "interop.md §The audit event",
|
|
33620
|
+
"requirement": null,
|
|
33621
|
+
"interpolated": true
|
|
33622
|
+
}
|
|
33623
|
+
]
|
|
33624
|
+
},
|
|
33625
|
+
{
|
|
33626
|
+
"id": "openwop.it.v2-negotiation-decided-inbound.an-inbound-a2a-exchange-at-preferredversion-is-recorded-accepted-with-requested",
|
|
33627
|
+
"file": "v2-negotiation-decided-inbound.test.ts",
|
|
33628
|
+
"line": 143,
|
|
33629
|
+
"title": "an inbound A2A exchange at preferredVersion is recorded accepted, with requested",
|
|
33630
|
+
"explicitId": null,
|
|
33631
|
+
"citations": []
|
|
33632
|
+
},
|
|
33633
|
+
{
|
|
33634
|
+
"id": "openwop.it.v2-negotiation-decided-inbound.an-inbound-a2a-exchange-below-minimumversion-is-recorded-refused-with-requested",
|
|
33635
|
+
"file": "v2-negotiation-decided-inbound.test.ts",
|
|
33636
|
+
"line": 146,
|
|
33637
|
+
"title": "an inbound A2A exchange below minimumVersion is recorded refused, with requested",
|
|
33638
|
+
"explicitId": null,
|
|
33639
|
+
"citations": []
|
|
33640
|
+
},
|
|
33641
|
+
{
|
|
33642
|
+
"id": "openwop.it.v2-negotiation-decided-inbound.an-inbound-mcp-exchange-at-preferredversion-is-recorded-accepted-with-requested",
|
|
33643
|
+
"file": "v2-negotiation-decided-inbound.test.ts",
|
|
33644
|
+
"line": 149,
|
|
33645
|
+
"title": "an inbound MCP exchange at preferredVersion is recorded accepted, with requested",
|
|
33646
|
+
"explicitId": null,
|
|
33647
|
+
"citations": []
|
|
33648
|
+
},
|
|
33649
|
+
{
|
|
33650
|
+
"id": "openwop.it.v2-negotiation-decided-inbound.an-inbound-mcp-exchange-below-minimumrevision-is-recorded-refused-with-requested",
|
|
33651
|
+
"file": "v2-negotiation-decided-inbound.test.ts",
|
|
33652
|
+
"line": 152,
|
|
33653
|
+
"title": "an inbound MCP exchange below minimumRevision is recorded refused, with requested",
|
|
33654
|
+
"explicitId": null,
|
|
33655
|
+
"citations": []
|
|
33656
|
+
},
|
|
33595
33657
|
{
|
|
33596
33658
|
"id": "openwop.it.v2-no-transport-advertisement.the-v2-root-carries-no-supportedtransports-and-no-grpc-block",
|
|
33597
33659
|
"file": "v2-no-transport-advertisement.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 653,
|
|
5
5
|
"v1": 455,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 213
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1581,6 +1581,9 @@
|
|
|
1581
1581
|
"v2-negotiation-decided-emitted.test.ts": [
|
|
1582
1582
|
2
|
|
1583
1583
|
],
|
|
1584
|
+
"v2-negotiation-decided-inbound.test.ts": [
|
|
1585
|
+
2
|
|
1586
|
+
],
|
|
1584
1587
|
"v2-no-transport-advertisement.test.ts": [
|
|
1585
1588
|
2
|
|
1586
1589
|
],
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.
|
|
5
|
-
"corpusTag": "v2.
|
|
4
|
+
"version": "2.46.0",
|
|
5
|
+
"corpusTag": "v2.46.0",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
8
8
|
"api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "d9a148a5136c34759cf8b92330e3a5f82dba06b25ce7ca22330e94b2c616bdde",
|
|
13
|
+
"api/v2/asyncapi.yaml": "c742c8d76aac957c9f508f15145e6440e195b13130a8ee2669d0d37f8dd4eaae",
|
|
14
|
+
"api/v2/openapi.yaml": "ea5ada57301b530f95f7410280e85fe96e32b91b96031fdae22df5e1d6c6e65f",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
"schemas/v2/goal.schema.json": "ad104ed5a3866736b8fed9d7c0b23dc82af526b5bef4afe848f01fc3af2161e9",
|
|
153
153
|
"schemas/v2/heartbeat-evaluated.schema.json": "c897b0792fd518d6e12879851027252af1529f5e420c3eaf434ad7af284ecc05",
|
|
154
154
|
"schemas/v2/heartbeat-state-changed.schema.json": "eb68d0082aa996650e3a6b8ccf32457f959cebc7e6728cc7c331b9d171a4bef6",
|
|
155
|
-
"schemas/v2/host-event.schema.json": "
|
|
155
|
+
"schemas/v2/host-event.schema.json": "8ecb5ae218ea6ba71b2aa52905c7851d125d6ca3c5108718739e84b3d6041d02",
|
|
156
156
|
"schemas/v2/ids.schema.json": "65cfaa193be0ca9cf48af39c18c553979e3ec1f7fc04509aaa513b322d7b7e4a",
|
|
157
157
|
"schemas/v2/localized-content-language-settings.schema.json": "3c8350a178037a460f5d87466ccaf9ed5fe3d41497786d2504cc66e42152c8cc",
|
|
158
158
|
"schemas/v2/localized-content-page-response.schema.json": "ef48329749758afb21e4d3cbc3f017abb911f75726819457f35b9f1390332c75",
|
|
@@ -174,7 +174,7 @@
|
|
|
174
174
|
"schemas/v2/residency.schema.json": "3fd23c9732beb140504e81560fc426eb6764d547e56da5fb92c5a5c7bf04db3a",
|
|
175
175
|
"schemas/v2/run-ancestry-response.schema.json": "334c17f2062dea62726ca1d07b8be3f169cdc422af843c1def5fb317039814c9",
|
|
176
176
|
"schemas/v2/run-diff-response.schema.json": "8bdb2045c274580038bd74781f7d062f5049839797c164a89de4ed89edc53a74",
|
|
177
|
-
"schemas/v2/run-event-payloads.schema.json": "
|
|
177
|
+
"schemas/v2/run-event-payloads.schema.json": "ff80204f4a9a55da482d8c7d54d6dbb8682e13fff155d4fc26fa5b7fa0790b83",
|
|
178
178
|
"schemas/v2/run-event.schema.json": "2f0d7416bd376c56affe97b6b2b49981532e8e9ff45c0a162cf806c78b3e7fb7",
|
|
179
179
|
"schemas/v2/run-list-response.schema.json": "c2dbc34768dcf64528914a213aa3cb55952e8e3e90a64b04a9423b2a19749d38",
|
|
180
180
|
"schemas/v2/run-options.schema.json": "f0e9f494d80086c2ed2cf9fe87df54e5b4e66601699c4a1089f4edbc7ba58867",
|
|
@@ -208,14 +208,14 @@
|
|
|
208
208
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
209
209
|
"spec/v1/alias-detectors.json": "22444c22bb88c568339ef512553f9fc38146e22b566d11ab8608f655d688d19c",
|
|
210
210
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
211
|
-
"spec/v1/core-standard-manifest.json": "
|
|
211
|
+
"spec/v1/core-standard-manifest.json": "1ecf2f7820fe47528c5c77bbe0d44ede95ef27cb1a8de209bb5104a4186c3722",
|
|
212
212
|
"spec/v1/deprecations.json": "e723bc11d7272c71789ad1303bb7f21809a8a024abf57594f9d5c35dc199ece9",
|
|
213
213
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
214
214
|
"spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
|
|
215
215
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
216
216
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
217
217
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
218
|
-
"spec/v1/gaps.json": "
|
|
218
|
+
"spec/v1/gaps.json": "7f26aee1bbae95945d29f4e2ecabb672c6833bc42f6f45f71e4880b7d09c130a",
|
|
219
219
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
220
220
|
"spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
|
|
221
221
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -228,7 +228,7 @@
|
|
|
228
228
|
"spec/v2/core/connection-packs.md": "c80f95bd6a03698f35c72cdb2b46bd973f49203bbd786c26760e35fa86e6f23d",
|
|
229
229
|
"spec/v2/core/conversation.md": "8d6abedf6af408a74577753bfca24719d318e2632e10b45ff5d63bdcf31a8aaa",
|
|
230
230
|
"spec/v2/core/errors.md": "e48838701b86bbfe0ffee77b18a8ffc4e5f04724b4318507d9771da653f8da0f",
|
|
231
|
-
"spec/v2/core/events.md": "
|
|
231
|
+
"spec/v2/core/events.md": "370621629c2439747f2e4ddab4780eea4345354f79e5098739d66823873f3105",
|
|
232
232
|
"spec/v2/core/execution.md": "8a0c9db1bbfb97c008ab9328b7333c1cc3318196e78bfbefb49e781bdf4f9d44",
|
|
233
233
|
"spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
|
|
234
234
|
"spec/v2/core/headers.md": "f69fe81e4a3ad21879bb85948660c16860eb05bf152928c31aa4898f6d7d1887",
|
|
@@ -236,7 +236,7 @@
|
|
|
236
236
|
"spec/v2/core/i18n.md": "882904b7d59f992ab1e5f7346167161a54cf665b8fee47e64a3862cf9133a580",
|
|
237
237
|
"spec/v2/core/idempotency.md": "adbd317a6ebb57c2759c076297b31f67def9e391576bccd0bbea787b13e85872",
|
|
238
238
|
"spec/v2/core/identity.md": "02656ffa413b5546342b8580ae928a74e621460746df74d7db50ff4df41d2310",
|
|
239
|
-
"spec/v2/core/interop.md": "
|
|
239
|
+
"spec/v2/core/interop.md": "6ec8259117bdb387b9323ca22c707b3b8571c00034e461c4ce79f4070c4d6b4a",
|
|
240
240
|
"spec/v2/core/interrupt.md": "10f15dfe30baa0fc748aea0a067f9b271bea316fde6b598d78f77edc296f698a",
|
|
241
241
|
"spec/v2/core/node-pack-runtimes.md": "e1088cb69b8c3c67256c494063328916c2980ab1aa97a7063de25bab82fdb1e5",
|
|
242
242
|
"spec/v2/core/oauth.md": "c069ae4f9aae2eafe14c07c0bf709aa7ade0dcc821973d740400d49d901f7061",
|
|
@@ -313,9 +313,9 @@
|
|
|
313
313
|
"spec/v2/path-manifest.json": "7b5679a4dad98c80d855361d88a98c8a4e045af15e310862a05b285b815dc9fc",
|
|
314
314
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
315
315
|
"spec/v2/profiles.json": "3d84d7712e93d1e823e362615d5f4aaba0789ea4e044b3774965876fbb8829a7",
|
|
316
|
-
"spec/v2/release.json": "
|
|
316
|
+
"spec/v2/release.json": "418d1ab00135f629e6e6e4b0dae213dc85dfc5418bdf3177f91d8508ad4f90cb",
|
|
317
317
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
318
|
-
"spec/v2/surface-baseline.json": "
|
|
318
|
+
"spec/v2/surface-baseline.json": "d17b41008a6572efd3b6d1178aa630be645b6cd135032814bcd4b618ad980811"
|
|
319
319
|
},
|
|
320
|
-
"corpusCommit": "
|
|
320
|
+
"corpusCommit": "5d0b99eeb2f8d2682516c73bc7dda9c18a0f1aee"
|
|
321
321
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0242 — judging a `negotiation.decided` record (`spec/v2/core/interop.md`
|
|
3
|
+
* §The audit event).
|
|
4
|
+
*
|
|
5
|
+
* Two facts the record must carry beyond RFC 0175 §D.3:
|
|
6
|
+
* - `requested`: the version the requesting side named (§A.2). Outbound the
|
|
7
|
+
* host is the requester, and the suite's fake peer saw what it asked for;
|
|
8
|
+
* inbound the suite is the requester and knows what it named.
|
|
9
|
+
* - where it lands: a negotiation with no run is a durable host event (§B.2),
|
|
10
|
+
* listed in `hostEvents.types[]` whenever an inbound surface is advertised
|
|
11
|
+
* (§B.3).
|
|
12
|
+
*
|
|
13
|
+
* Pure functions only, so `negotiation-record.test.ts` can prove each judge
|
|
14
|
+
* fails on the defect it exists to catch.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface Finding { readonly ok: boolean; readonly message: string }
|
|
18
|
+
|
|
19
|
+
export const EVENT = 'negotiation.decided';
|
|
20
|
+
|
|
21
|
+
interface Invocation { readonly headers: Readonly<Record<string, string>>; readonly params?: unknown }
|
|
22
|
+
|
|
23
|
+
const META_V = 'io.modelcontextprotocol/protocolVersion';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The version the host asked a suite peer for, read from what the peer
|
|
27
|
+
* received: A2A's `A2A-Version` header; MCP's `MCP-Protocol-Version` header,
|
|
28
|
+
* else the `_meta` protocol version, else legacy `initialize`'s
|
|
29
|
+
* `protocolVersion`. `null` when the host named none, in which case §A.2 does
|
|
30
|
+
* not require `requested`.
|
|
31
|
+
*/
|
|
32
|
+
export function askedVersion(protocol: 'a2a' | 'mcp', invocations: readonly Invocation[]): string | null {
|
|
33
|
+
for (const inv of invocations) {
|
|
34
|
+
if (protocol === 'a2a') {
|
|
35
|
+
const v = inv.headers['a2a-version'];
|
|
36
|
+
if (typeof v === 'string' && v.length > 0) return v;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const h = inv.headers['mcp-protocol-version'];
|
|
40
|
+
if (typeof h === 'string' && h.length > 0) return h;
|
|
41
|
+
const p = (inv.params ?? {}) as Record<string, unknown>;
|
|
42
|
+
const meta = (p['_meta'] ?? {}) as Record<string, unknown>;
|
|
43
|
+
if (typeof meta[META_V] === 'string') return meta[META_V] as string;
|
|
44
|
+
if (typeof p['protocolVersion'] === 'string') return p['protocolVersion'] as string;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** §A.2: `requested` is present and equals what was named. */
|
|
50
|
+
export function judgeRequested(payload: Readonly<Record<string, unknown>>, named: string): Finding {
|
|
51
|
+
const got = payload['requested'];
|
|
52
|
+
if (typeof got !== 'string') return { ok: false, message: `${EVENT} MUST carry requested when a version was named (named ${named}; the record has none) — RFC 0242 §A.2` };
|
|
53
|
+
return got === named
|
|
54
|
+
? { ok: true, message: 'requested matches the named version' }
|
|
55
|
+
: { ok: false, message: `${EVENT}.requested MUST equal the version the requesting side named: named ${named}, recorded ${got} — RFC 0242 §A.2` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether discovery advertises an inbound A2A or MCP surface (§B.3's condition). */
|
|
59
|
+
export function inboundServed(doc: Readonly<Record<string, unknown>>): { a2a: boolean; mcp: boolean } {
|
|
60
|
+
const a2a = doc['a2a'] as Record<string, unknown> | undefined;
|
|
61
|
+
const mcp = doc['mcp'] as Record<string, unknown> | undefined;
|
|
62
|
+
const urls = Array.isArray(mcp?.['serverUrls']) ? (mcp!['serverUrls'] as unknown[]) : [];
|
|
63
|
+
return {
|
|
64
|
+
a2a: typeof a2a?.['agentCardUrl'] === 'string',
|
|
65
|
+
mcp: mcp !== undefined && (urls.length > 0 || mcp['serverMount'] !== undefined),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** §B.3: an inbound surface implies `{ type: negotiation.decided, delivery: durable }` in `hostEvents.types[]`. */
|
|
70
|
+
export function judgeListed(doc: Readonly<Record<string, unknown>>): Finding {
|
|
71
|
+
const served = inboundServed(doc);
|
|
72
|
+
const which = [served.a2a ? 'a2a.agentCardUrl' : null, served.mcp ? 'mcp.serverUrls/serverMount' : null].filter(Boolean).join(' and ');
|
|
73
|
+
const he = doc['hostEvents'] as Record<string, unknown> | undefined;
|
|
74
|
+
if (he === undefined) return { ok: false, message: `the host serves ${which} inbound, so it MUST advertise hostEvents listing ${EVENT} (durable) — RFC 0242 §B.3` };
|
|
75
|
+
const types = Array.isArray(he['types']) ? (he['types'] as Array<Record<string, unknown>>) : [];
|
|
76
|
+
const row = types.find((t) => t?.['type'] === EVENT);
|
|
77
|
+
if (!row) return { ok: false, message: `the host serves ${which} inbound, so hostEvents.types[] MUST list ${EVENT} — RFC 0242 §B.3` };
|
|
78
|
+
return row['delivery'] === 'durable'
|
|
79
|
+
? { ok: true, message: `${EVENT} listed durable` }
|
|
80
|
+
: { ok: false, message: `${EVENT} MUST be listed with delivery durable, not ${String(row['delivery'])} — RFC 0242 §B.2` };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* §B.2: the host event recording one inbound exchange. `envelopes` are the
|
|
85
|
+
* parsed `data:` of every `negotiation.decided` frame seen while the exchange
|
|
86
|
+
* ran; the judge picks the one whose `requested` is the version the suite
|
|
87
|
+
* named, so a concurrent exchange cannot satisfy it.
|
|
88
|
+
*/
|
|
89
|
+
export function judgeInboundRecord(
|
|
90
|
+
envelopes: readonly Record<string, unknown>[],
|
|
91
|
+
expect: { protocol: 'a2a' | 'mcp'; named: string; outcome: 'accepted' | 'refused' },
|
|
92
|
+
): Finding[] {
|
|
93
|
+
const mine = envelopes.filter((e) => ((e['payload'] ?? {}) as Record<string, unknown>)['requested'] === expect.named
|
|
94
|
+
&& ((e['payload'] ?? {}) as Record<string, unknown>)['protocol'] === expect.protocol);
|
|
95
|
+
if (mine.length === 0) {
|
|
96
|
+
return [{ ok: false, message: `an inbound ${expect.protocol} exchange naming ${expect.named} MUST leave a ${EVENT} host event on /host/events with requested ${expect.named}; ${envelopes.length} ${EVENT} frame(s) seen, none matching — RFC 0242 §B.2, §A.2` }];
|
|
97
|
+
}
|
|
98
|
+
const e = mine[mine.length - 1]!;
|
|
99
|
+
const p = (e['payload'] ?? {}) as Record<string, unknown>;
|
|
100
|
+
const out: Finding[] = [];
|
|
101
|
+
out.push(e['delivery'] === 'durable' ? { ok: true, message: 'durable' } : { ok: false, message: `the host event MUST be durable, got ${String(e['delivery'])} — RFC 0242 §B.2` });
|
|
102
|
+
out.push(!('runId' in e) && !('sequence' in e) ? { ok: true, message: 'runless' } : { ok: false, message: 'a host event carries no runId or sequence — events.md §Host events' });
|
|
103
|
+
out.push(p['outcome'] === expect.outcome ? { ok: true, message: 'outcome' } : { ok: false, message: `the exchange was ${expect.outcome === 'accepted' ? 'served' : 'refused'}, so the recorded outcome MUST be ${expect.outcome}, got ${String(p['outcome'])}` });
|
|
104
|
+
if (expect.outcome === 'accepted') {
|
|
105
|
+
out.push(typeof p['version'] === 'string' ? { ok: true, message: 'version' } : { ok: false, message: 'an accepted record names the version used — interop.md §The audit event' });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
* Callback-shaped (the host calls the suite's peer): unwitnessable when the
|
|
23
23
|
* host is in a separate network namespace — `../lib/host-callback.ts`.
|
|
24
24
|
*
|
|
25
|
+
* RFC 0242 §A.2 adds two legs: the record's `requested` equals the version the
|
|
26
|
+
* host's client named to the suite's peer (read from what the peer received).
|
|
27
|
+
*
|
|
25
28
|
* @see spec/v2/core/interop.md §Negotiation is a protocol
|
|
26
29
|
*/
|
|
27
30
|
|
|
@@ -39,6 +42,7 @@ import { getA2AFakePeer } from '../lib/a2a-fake-peer.js';
|
|
|
39
42
|
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
40
43
|
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
41
44
|
import { req } from '../lib/requirement-ids.js';
|
|
45
|
+
import { askedVersion, judgeRequested } from '../lib/negotiation-record.js';
|
|
42
46
|
|
|
43
47
|
export const REQUIRES_HOST_CALLBACK = "the host's A2A/MCP client calls the suite's fake peer/server through the invoke seams; the negotiation.decided event is then read on the host's own log";
|
|
44
48
|
|
|
@@ -83,7 +87,10 @@ function originDigest(url: string): string {
|
|
|
83
87
|
return createHash('sha256').update(new URL(url).origin, 'utf8').digest('hex');
|
|
84
88
|
}
|
|
85
89
|
|
|
86
|
-
|
|
90
|
+
type Exchange = { driven: { status: number; body: Record<string, unknown> }; runId: string; decided: EventRow[]; peer: NonNullable<ReturnType<typeof getA2AFakePeer>> | NonNullable<ReturnType<typeof getMcpFakeServer>>; validate: NonNullable<ReturnType<typeof payloadValidator>> };
|
|
91
|
+
|
|
92
|
+
/** Gate, drive one exchange through the seam, and read the run's records; `undefined` once a skip is recorded. */
|
|
93
|
+
async function exchange(protocol: 'a2a' | 'mcp'): Promise<Exchange | undefined> {
|
|
87
94
|
const doc = await discovery();
|
|
88
95
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
89
96
|
const facet = await familyAdvertised(protocol);
|
|
@@ -94,15 +101,31 @@ async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
|
94
101
|
peer.reset();
|
|
95
102
|
const validate = payloadValidator();
|
|
96
103
|
if (!validate) return softSkip('blocked', 'run-event-payloads.schema.json#/$defs/negotiationDecided not readable from SCHEMAS_DIR');
|
|
97
|
-
|
|
98
104
|
const driven = await drive(protocol, peer.hostFacingEndpoint());
|
|
99
105
|
if (!driven) return softSkip('blocked', 'invoke seam unavailable (reason recorded above)');
|
|
100
106
|
const runId = driven.body['runId'];
|
|
101
107
|
if (typeof runId !== 'string') {
|
|
102
108
|
return softSkip('blocked', `the ${protocol} invoke seam answered ${driven.status} without a runId — the host log that carries ${EVENT} is not addressable from the suite (the seam MUST name the run whose log recorded the decision)`);
|
|
103
109
|
}
|
|
104
|
-
const
|
|
105
|
-
|
|
110
|
+
const decided = (await eventsOf(runId)).filter((e) => e.type === EVENT);
|
|
111
|
+
return { driven, runId, decided, peer, validate };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** RFC 0242 §A.2: the record names the version the host asked the peer for. */
|
|
115
|
+
async function requestedLeg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
116
|
+
const x = await exchange(protocol);
|
|
117
|
+
if (!x) return;
|
|
118
|
+
const named = askedVersion(protocol, x.peer.invocations());
|
|
119
|
+
if (named === null) return softSkip('inapplicable', `the host's ${protocol} client named no version to the suite's peer, so RFC 0242 §A.2 does not require requested`);
|
|
120
|
+
expect(x.decided.length, req(id, 'interop.md §The audit event', `the exchange MUST leave ${EVENT} on run ${x.runId} (RFC 0175 §D.3)`)).toBeGreaterThan(0);
|
|
121
|
+
const f = judgeRequested(x.decided[x.decided.length - 1]!.payload ?? {}, named);
|
|
122
|
+
expect(f.ok, req(id, 'interop.md §The audit event', f.message)).toBe(true);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
126
|
+
const x = await exchange(protocol);
|
|
127
|
+
if (!x) return;
|
|
128
|
+
const { driven, runId, decided, peer, validate } = x;
|
|
106
129
|
expect(
|
|
107
130
|
decided.length,
|
|
108
131
|
req(id, 'interop.md §The audit event', `every ${protocol} negotiation outcome MUST emit ${EVENT} on the host's own event log (RFC 0175 §D.3) — none found on run ${runId}`),
|
|
@@ -146,4 +169,10 @@ describe('RFC 0175 §D.3 — negotiation-decided-emitted (gated on a2a/mcp + sea
|
|
|
146
169
|
it('an MCP exchange leaves negotiation.decided on the host log', async () => {
|
|
147
170
|
await leg('mcp', 'openwop.requirement.0175.negotiation-decided-emitted.mcp');
|
|
148
171
|
});
|
|
172
|
+
it('an A2A record names the version the host asked for (RFC 0242 §A.2)', async () => {
|
|
173
|
+
await requestedLeg('a2a', 'openwop.requirement.0242.requested.outbound');
|
|
174
|
+
});
|
|
175
|
+
it('an MCP record names the revision the host asked for (RFC 0242 §A.2)', async () => {
|
|
176
|
+
await requestedLeg('mcp', 'openwop.requirement.0242.requested.outbound.mcp');
|
|
177
|
+
});
|
|
149
178
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0242 §B — an inbound negotiation is recorded as a durable host event
|
|
3
|
+
* (suite 2.46.0, target major 2; gated on an inbound a2a/mcp surface).
|
|
4
|
+
*
|
|
5
|
+
* `spec/v2/core/interop.md` §The audit event: every negotiation outcome MUST
|
|
6
|
+
* emit a `negotiation.decided` record — on its run's log, or, with no run, as
|
|
7
|
+
* the caller's durable host event (`schemas/v2/host-event.schema.json`,
|
|
8
|
+
* `payload` = `run-event-payloads.schema.json#/$defs/negotiationDecided`). A
|
|
9
|
+
* host serving either protocol inbound (`a2a.agentCardUrl`, `mcp.serverUrls`,
|
|
10
|
+
* `mcp.serverMount`) MUST list the type as durable in `hostEvents.types[]`,
|
|
11
|
+
* and `requested` MUST carry the version the caller named.
|
|
12
|
+
*
|
|
13
|
+
* How it is witnessed, with no seam: the suite reads discovery for §B.3, then
|
|
14
|
+
* acts as an authenticated inbound client. With `/host/events` open it makes
|
|
15
|
+
* one exchange naming `preferredVersion` (expected `accepted`) and one naming
|
|
16
|
+
* a version below the floor (expected `refused`), and finds each record by its
|
|
17
|
+
* `requested`. A2A names the version in `A2A-Version` on a JSON-RPC `GetTask`
|
|
18
|
+
* for a task that does not exist; MCP names it in `MCP-Protocol-Version` and
|
|
19
|
+
* `_meta` on `tools/list`. Neither call changes host state.
|
|
20
|
+
*
|
|
21
|
+
* @see RFCS/0242-negotiation-decided-requested-and-inbound.md
|
|
22
|
+
* @see spec/v2/core/interop.md §Negotiation is a protocol
|
|
23
|
+
* @see spec/v2/core/events.md §Host events
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { randomBytes } from 'node:crypto';
|
|
28
|
+
import { v2Discovery, familyAdvertised, v2Validator } from '../lib/v2.js';
|
|
29
|
+
import { loadEnv } from '../lib/env.js';
|
|
30
|
+
import { subscribe } from '../lib/sse.js';
|
|
31
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
import { EVENT, inboundServed, judgeInboundRecord, judgeListed } from '../lib/negotiation-record.js';
|
|
34
|
+
|
|
35
|
+
const DOC = 'interop.md §The audit event';
|
|
36
|
+
const ID_LISTED = 'openwop.requirement.0242.inbound.listed';
|
|
37
|
+
const ID_A2A = 'openwop.requirement.0242.inbound.recorded';
|
|
38
|
+
const ID_A2A_REFUSED = 'openwop.requirement.0242.inbound.refused';
|
|
39
|
+
const ID_MCP = 'openwop.requirement.0242.inbound.recorded.mcp';
|
|
40
|
+
const ID_MCP_REFUSED = 'openwop.requirement.0242.inbound.refused.mcp';
|
|
41
|
+
const A2A_BELOW = '0.3';
|
|
42
|
+
const MCP_BELOW = '2025-06-18';
|
|
43
|
+
const META_V = 'io.modelcontextprotocol/protocolVersion';
|
|
44
|
+
const STREAM_MS = 3_000;
|
|
45
|
+
|
|
46
|
+
const hostEvent = v2Validator('host-event');
|
|
47
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
48
|
+
const absolute = (u: string): string => (/^https?:\/\//i.test(u) ? u : `${loadEnv().baseUrl}${u.startsWith('/') ? '' : '/'}${u}`);
|
|
49
|
+
|
|
50
|
+
/** `a.b` versions and ISO-date revisions both order correctly as compared here. */
|
|
51
|
+
function below(candidate: string, floor: string): boolean {
|
|
52
|
+
if (/^\d+\.\d+$/.test(candidate) && /^\d+\.\d+$/.test(floor)) {
|
|
53
|
+
const [cm, cn] = candidate.split('.').map(Number);
|
|
54
|
+
const [fm, fn] = floor.split('.').map(Number);
|
|
55
|
+
return cm! < fm! || (cm === fm && cn! < fn!);
|
|
56
|
+
}
|
|
57
|
+
return candidate < floor;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Surface = { url: string; preferred: string; floor: string };
|
|
61
|
+
type Gate = { ok: true; doc: Record<string, unknown>; surface: Surface } | { ok: false; kind: 'blocked' | 'inapplicable'; reason: string };
|
|
62
|
+
|
|
63
|
+
async function gate(protocol: 'a2a' | 'mcp'): Promise<Gate> {
|
|
64
|
+
let doc: Record<string, unknown> | null;
|
|
65
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
66
|
+
if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
67
|
+
const facet = await familyAdvertised(protocol);
|
|
68
|
+
if (!facet || !inboundServed(doc)[protocol]) return { ok: false, kind: 'inapplicable', reason: `the host serves no inbound ${protocol} surface (${protocol === 'a2a' ? 'a2a.agentCardUrl' : 'mcp.serverUrls / mcp.serverMount'} absent)` };
|
|
69
|
+
if (!judgeListed(doc).ok) return { ok: false, kind: 'inapplicable', reason: `hostEvents.types[] does not list ${EVENT}, so the host emits no such host event; ${ID_LISTED} records that failure` };
|
|
70
|
+
const preferred = facet['preferredVersion'];
|
|
71
|
+
const floor = protocol === 'a2a' ? facet['minimumVersion'] : facet['minimumRevision'];
|
|
72
|
+
if (typeof preferred !== 'string' || typeof floor !== 'string') return { ok: false, kind: 'blocked', reason: `the ${protocol} facet lacks preferredVersion or its floor` };
|
|
73
|
+
if (protocol === 'mcp') {
|
|
74
|
+
const urls = Array.isArray(facet['serverUrls']) ? (facet['serverUrls'] as unknown[]).filter((u): u is string => typeof u === 'string' && u.length > 0) : [];
|
|
75
|
+
if (urls.length === 0) return { ok: false, kind: 'blocked', reason: 'mcp is served inbound with no mcp.serverUrls[0] — the mount is unaddressable' };
|
|
76
|
+
return { ok: true, doc, surface: { url: absolute(urls[0]!), preferred, floor } };
|
|
77
|
+
}
|
|
78
|
+
const res = await fetch(String(facet['agentCardUrl']), { headers: { accept: 'application/json', 'A2A-Version': preferred } }).catch(() => null);
|
|
79
|
+
if (!res || res.status !== 200) return { ok: false, kind: 'blocked', reason: `the advertised agentCardUrl answered ${res?.status ?? 'nothing'}` };
|
|
80
|
+
const card = (await res.json().catch(() => ({}))) as { supportedInterfaces?: Array<{ url?: string; protocolBinding?: string }> };
|
|
81
|
+
const iface = (card.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'JSONRPC' && typeof i.url === 'string');
|
|
82
|
+
if (!iface) return { ok: false, kind: 'blocked', reason: 'the public agent card lists no JSONRPC interface to send an inbound request to' };
|
|
83
|
+
return { ok: true, doc, surface: { url: absolute(iface.url!), preferred, floor } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One inbound exchange naming `version`; the caller's bearer authenticates it (`interop.md` §Authentication). */
|
|
87
|
+
async function exchange(protocol: 'a2a' | 'mcp', url: string, version: string): Promise<void> {
|
|
88
|
+
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: `Bearer ${loadEnv().apiKey}` };
|
|
89
|
+
const id = randomBytes(4).readUInt32BE(0);
|
|
90
|
+
let body: Record<string, unknown>;
|
|
91
|
+
if (protocol === 'a2a') {
|
|
92
|
+
headers['A2A-Version'] = version;
|
|
93
|
+
body = { jsonrpc: '2.0', id, method: 'GetTask', params: { id: `conf-0242-${randomBytes(8).toString('hex')}` } };
|
|
94
|
+
} else {
|
|
95
|
+
headers['MCP-Protocol-Version'] = version;
|
|
96
|
+
headers['Mcp-Method'] = 'tools/list';
|
|
97
|
+
body = { jsonrpc: '2.0', id, method: 'tools/list', params: { _meta: { [META_V]: version } } };
|
|
98
|
+
}
|
|
99
|
+
await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }).then((r) => r.text()).catch(() => undefined);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Open `/host/events`, make the exchange, and return the parsed envelope of every `negotiation.decided` frame. */
|
|
103
|
+
async function recordsOf(protocol: 'a2a' | 'mcp', url: string, version: string): Promise<{ envelopes: Record<string, unknown>[]; invalid: string[] }> {
|
|
104
|
+
const sub = subscribe('/host/events', { timeoutMs: STREAM_MS, extraHeaders: { 'OpenWOP-Version': '2.0' } });
|
|
105
|
+
await sleep(400);
|
|
106
|
+
await exchange(protocol, url, version);
|
|
107
|
+
const frames = (await sub).events.filter((f) => f.event === EVENT);
|
|
108
|
+
const envelopes: Record<string, unknown>[] = [];
|
|
109
|
+
const invalid: string[] = [];
|
|
110
|
+
for (const f of frames) {
|
|
111
|
+
let env: unknown;
|
|
112
|
+
try { env = JSON.parse(f.data); } catch { invalid.push('non-JSON data'); continue; }
|
|
113
|
+
const v = hostEvent(env);
|
|
114
|
+
if (!v.ok) invalid.push(v.errors);
|
|
115
|
+
if (env && typeof env === 'object') envelopes.push(env as Record<string, unknown>);
|
|
116
|
+
}
|
|
117
|
+
return { envelopes, invalid };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function inboundLeg(protocol: 'a2a' | 'mcp', id: string, which: 'accepted' | 'refused'): Promise<void> {
|
|
121
|
+
const g = await gate(protocol);
|
|
122
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
123
|
+
const candidate = protocol === 'a2a' ? A2A_BELOW : MCP_BELOW;
|
|
124
|
+
if (which === 'refused' && !below(candidate, g.surface.floor)) {
|
|
125
|
+
return softSkip('inapplicable', `the ${protocol} floor ${g.surface.floor} is at or below ${candidate}, the lowest version the suite can name — nothing below the floor to ask for`);
|
|
126
|
+
}
|
|
127
|
+
const named = which === 'accepted' ? g.surface.preferred : candidate;
|
|
128
|
+
const { envelopes, invalid } = await recordsOf(protocol, g.surface.url, named);
|
|
129
|
+
expect(invalid, req(id, 'events.md §Host events', `every ${EVENT} host event MUST validate against host-event.schema.json: ${invalid.join('; ')}`)).toEqual([]);
|
|
130
|
+
for (const f of judgeInboundRecord(envelopes, { protocol, named, outcome: which })) expect(f.ok, req(id, DOC, f.message)).toBe(true);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe('RFC 0242 §B — an inbound negotiation is a durable host event (gated on an inbound a2a/mcp surface)', () => {
|
|
134
|
+
it('a host serving A2A or MCP inbound lists negotiation.decided as a durable host-event type', async () => {
|
|
135
|
+
let doc: Record<string, unknown> | null;
|
|
136
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
137
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
138
|
+
const served = inboundServed(doc);
|
|
139
|
+
if (!served.a2a && !served.mcp) return softSkip('inapplicable', 'the host advertises no inbound A2A or MCP surface (a2a.agentCardUrl, mcp.serverUrls, mcp.serverMount)');
|
|
140
|
+
const f = judgeListed(doc);
|
|
141
|
+
expect(f.ok, req(ID_LISTED, DOC, f.message)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
it('an inbound A2A exchange at preferredVersion is recorded accepted, with requested', async () => {
|
|
144
|
+
await inboundLeg('a2a', ID_A2A, 'accepted');
|
|
145
|
+
});
|
|
146
|
+
it('an inbound A2A exchange below minimumVersion is recorded refused, with requested', async () => {
|
|
147
|
+
await inboundLeg('a2a', ID_A2A_REFUSED, 'refused');
|
|
148
|
+
});
|
|
149
|
+
it('an inbound MCP exchange at preferredVersion is recorded accepted, with requested', async () => {
|
|
150
|
+
await inboundLeg('mcp', ID_MCP, 'accepted');
|
|
151
|
+
});
|
|
152
|
+
it('an inbound MCP exchange below minimumRevision is recorded refused, with requested', async () => {
|
|
153
|
+
await inboundLeg('mcp', ID_MCP_REFUSED, 'refused');
|
|
154
|
+
});
|
|
155
|
+
});
|