@openwop/openwop-conformance 2.45.27 → 2.45.29
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/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-purpose-relay.json +26 -0
- package/fixtures.md +9 -0
- package/package.json +2 -2
- package/requirements.json +8 -13
- package/schemas/CORPUS-STAMP.json +10 -10
- package/src/lib/purpose-relay-witness.ts +54 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +2 -1
- package/src/scenarios/v2-mcp-mount-map.test.ts +2 -1
- package/src/scenarios/v2-purpose-propagation-onward.test.ts +119 -54
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +13 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.45.29] — 2026-10-08 — purpose labels witnessed without a seam; RFC 0241 Accepted
|
|
4
|
+
|
|
5
|
+
- **`v2-purpose-propagation-onward` witnesses purpose labels through normative operations.** New fixture `conformance-purpose-relay`: routed as the only skill of one agent's A2A card, it relays an inbound A2A message's text to the suite's fake peer through `core.conformance.a2a-invoke`. The suite labels the inbound message with `metadata.openwop.permittedPurposes`, finds the agent through `GET /agents` and `GetExtendedAgentCard`, and reads the onward label at its peer; a nonce matches each onward message to its inbound one. The §22 seam stays as the fallback, and each note names the path. A host with the fixture advertised but no card routing it, or with the suite's peer not running, records `blocked`. Self-test double: a widened label, a dropped label and a call made under `[]` each fail their leg (`lib/purpose-relay-witness.test.ts`). Proven on a seams-off v2 reference host (openwop-examples #166): both rows `executed-pass`, and each defect `executed-fail`. This replaces the planned RFC 0240: the label already arrives on a real carrier.
|
|
6
|
+
- **No scenario change.** RFC 0241 goes `Accepted` on the v2 reference host's certified seams-off cut on 2.45.28; its gaps G1 and G2 close, which changes the packed `spec/v1/gaps.json`.
|
|
7
|
+
|
|
8
|
+
## [2.45.28] — 2026-10-08 — two more seam-driven legs, and the backoff ratio, on a public seam-free cut
|
|
9
|
+
|
|
10
|
+
- **Found by the v2 reference host's first public seams-off cut** (2.45.27, RFC 0241's evidence): all seven target rows `executed-pass`, but four other rows kept the bundle from certifying. Neither shows on a loopback cut.
|
|
11
|
+
- **`0199.mcp-url-mode` (`v2-mcp-mount-map`) and `0199.a2a-auth-required` (`v2-a2a-operation-map`):** both mint a fresh Subject through the credential mint seam, without checking for the seams profile, and recorded `blocked` when it was absent. They run only where `oauth.credentialInterrupt` is advertised, which this host does only with an https public base. Now `inapplicable` without the seams profile.
|
|
12
|
+
- **`v2-webhook-durable-delivery`, the exponential-backoff check:** it required the second retry gap to be at least 1.5x the first. Each gap also carries a constant, the receiver round trip a host waits on before scheduling the retry, so the gaps are B + c and 2B + c; through a tunnel (c ≈ 270 ms, base 500 ms) a correct host measured 1.43x. The second gap must now exceed the first by at least a quarter of it.
|
|
13
|
+
- Proven on the v2 reference host: seams off with an https base, both 0199 rows `inapplicable`; the backoff leg passes on the host as is, and fails (510 ms after 604 ms) with the backoff made constant.
|
|
14
|
+
|
|
3
15
|
## [2.45.27] — 2026-10-08 — a seam-free host's seam-driven legs record `inapplicable`, not `blocked`
|
|
4
16
|
|
|
5
17
|
- **Three scenarios recorded `blocked` on a host without the seams profile**, which denies the bundle certification, where the rest of the suite records a seam-driven leg `inapplicable`. Found by the v2 reference host's first seams-off cut (RFC 0241's evidence): 11 rows `blocked`, all `executed-pass` with the seams profile.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "conformance-purpose-relay",
|
|
3
|
+
"name": "Conformance: Purpose Relay",
|
|
4
|
+
"version": "1.0",
|
|
5
|
+
"description": "security-defaults.md §Onward hops. Routed as the only skill of one agent's A2A card: an inbound A2A message starts it, and one core.conformance.a2a-invoke node sends that message's text parts on to the A2A peer the operator configured. The suite labels the inbound message with metadata.openwop.permittedPurposes and reads the onward message at its peer. See conformance/fixtures.md.",
|
|
6
|
+
"nodes": [
|
|
7
|
+
{
|
|
8
|
+
"id": "relay",
|
|
9
|
+
"typeId": "core.conformance.a2a-invoke",
|
|
10
|
+
"name": "Relay the inbound message",
|
|
11
|
+
"position": { "x": 0, "y": 0 },
|
|
12
|
+
"config": {
|
|
13
|
+
"skill": "echo",
|
|
14
|
+
"forward": "inbound-message"
|
|
15
|
+
},
|
|
16
|
+
"inputs": {}
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"edges": [],
|
|
20
|
+
"triggers": [
|
|
21
|
+
{ "id": "manual", "type": "manual", "enabled": true }
|
|
22
|
+
],
|
|
23
|
+
"variables": [],
|
|
24
|
+
"metadata": { "tags": ["conformance", "a2a", "purpose-propagation"] },
|
|
25
|
+
"settings": { "timeout": 30000 }
|
|
26
|
+
}
|
package/fixtures.md
CHANGED
|
@@ -44,6 +44,7 @@ All fixtures MUST advertise:
|
|
|
44
44
|
| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------- |
|
|
45
45
|
| Noop | `conformance-noop` | Cheapest possible run-lifecycle test | `completed` | ≤ 5s |
|
|
46
46
|
| Nondeterminism Sources | `conformance-nondeterminism` | RFC 0237 §D — one `core.conformance.nondeterminism` node draws one value per source the host lists in `nondeterminismPolicy.sources` into the run outputs; a `replay` fork from `fromSeq: 0` MUST reproduce each | `completed`; replay outputs byte-equal per listed source | ≤ 10s |
|
|
47
|
+
| Purpose Relay | `conformance-purpose-relay` | `security-defaults.md` §Onward hops — the only skill of one agent's A2A card; one `core.conformance.a2a-invoke` node sends the inbound message's text on to the configured peer, re-emitting its `permittedPurposes` label | `completed`, or `failed` when the label is `[]` | ≤ 30s |
|
|
47
48
|
| Identity | `conformance-identity` | Verifies input/output passthrough | `completed` | ≤ 5s |
|
|
48
49
|
| Delay | `conformance-delay` | Verifies poll/SSE behavior over time | `completed` | ≤ 30s (input-controlled) |
|
|
49
50
|
| Failure | `conformance-failure` | Verifies error-event surface | `failed` | ≤ 5s |
|
|
@@ -160,6 +161,14 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
|
|
|
160
161
|
- **Expected behavior**: the run reaches `completed`, and `run.completed.outputs` carries one string per listed source. A `replay` fork with `fromSeq: 0` reaches `completed` with byte-equal outputs for every listed source.
|
|
161
162
|
- **Advertise only if** the host lists `nondeterminismPolicy.sources` and records the drawn values. Consumed by `v2-nondeterminism-sources`.
|
|
162
163
|
|
|
164
|
+
### `conformance-purpose-relay`
|
|
165
|
+
|
|
166
|
+
- **Purpose**: witness `security-defaults.md` §Onward hops through normative operations: the label arrives on a real carrier (an inbound A2A message's `metadata.openwop.permittedPurposes`) and leaves on a real onward hop. No seam.
|
|
167
|
+
- **Shape**: one node, `relay` (`core.conformance.a2a-invoke`, the reserved typeId `conformance-a2a-task-roundtrip` uses), with `config.forward: "inbound-message"`: it sends the text parts of the A2A message that started the run, unchanged, to the A2A peer the operator configured (the suite's fake peer, `OPENWOP_A2A_FAKE_PEER=true`).
|
|
168
|
+
- **Routing**: the host routes this workflow as the only skill (`skills[].id` = `conformance-purpose-relay`) of one agent in the caller's `GET /agents` inventory, reached through that agent's `a2aTenant` (`interop.md` §Per-agent cards). It is never a skill of the host card, which keeps `conformance-approval` as its one skill.
|
|
169
|
+
- **Expected behavior**: an inbound `SendMessage` to that agent starts one run. With no label, or a non-empty one, the peer receives one `SendMessage` whose text parts equal the inbound ones and, when labelled, whose `metadata.openwop.permittedPurposes` is a subset of the received label. With `[]`, the peer receives nothing; the run's terminal status is the host's.
|
|
170
|
+
- **Advertise only if** the host advertises `purposePropagation` with an onward hop, the `a2a-1.0` profile and `a2a.agentCards`, and its A2A client re-emits labels. Consumed by `v2-purpose-propagation-onward`.
|
|
171
|
+
|
|
163
172
|
### `conformance-noop`
|
|
164
173
|
|
|
165
174
|
- **Purpose**: cheapest run-lifecycle test. Used by `runs-lifecycle.test.ts` (v1) and `v2-created-run-readable.test.ts` (v2), and by most run-surface scenarios, to verify create, read and the terminal event end-to-end.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.45.
|
|
3
|
+
"version": "2.45.29",
|
|
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.45.
|
|
59
|
+
"@openwop/spec-artifacts": "2.45.29"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -32919,7 +32919,7 @@
|
|
|
32919
32919
|
{
|
|
32920
32920
|
"id": "openwop.it.v2-mcp-mount-map.form-mode-is-never-emitted-for-a-nested-schema-or-a-sensitive-format-password-fi",
|
|
32921
32921
|
"file": "v2-mcp-mount-map.test.ts",
|
|
32922
|
-
"line":
|
|
32922
|
+
"line": 345,
|
|
32923
32923
|
"title": "form mode is never emitted for a nested schema or a sensitive (format password) field",
|
|
32924
32924
|
"explicitId": "openwop.requirement.0199.form-mode-no-secret",
|
|
32925
32925
|
"citations": [
|
|
@@ -35437,15 +35437,10 @@
|
|
|
35437
35437
|
{
|
|
35438
35438
|
"id": "openwop.it.v2-purpose-propagation-onward.a-received-label-is-re-emitted-on-the-onward-message-never-widened",
|
|
35439
35439
|
"file": "v2-purpose-propagation-onward.test.ts",
|
|
35440
|
-
"line":
|
|
35440
|
+
"line": 147,
|
|
35441
35441
|
"title": "a received label is re-emitted on the onward message, never widened",
|
|
35442
35442
|
"explicitId": "openwop.requirement.purpose-propagation.onward-reemit-no-widen",
|
|
35443
35443
|
"citations": [
|
|
35444
|
-
{
|
|
35445
|
-
"section": "spec/v2/core/security-defaults.md §Onward hops",
|
|
35446
|
-
"requirement": null,
|
|
35447
|
-
"interpolated": true
|
|
35448
|
-
},
|
|
35449
35444
|
{
|
|
35450
35445
|
"section": "spec/v2/core/security-defaults.md §Onward hops",
|
|
35451
35446
|
"requirement": null,
|
|
@@ -35456,7 +35451,7 @@
|
|
|
35456
35451
|
{
|
|
35457
35452
|
"id": "openwop.it.v2-purpose-propagation-onward.an-empty-label-means-no-onward-call-while-an-unlabelled-control-call-goes-throug",
|
|
35458
35453
|
"file": "v2-purpose-propagation-onward.test.ts",
|
|
35459
|
-
"line":
|
|
35454
|
+
"line": 164,
|
|
35460
35455
|
"title": "an empty label means no onward call, while an unlabelled control call goes through",
|
|
35461
35456
|
"explicitId": "openwop.requirement.purpose-propagation.empty-no-onward",
|
|
35462
35457
|
"citations": [
|
|
@@ -38110,7 +38105,7 @@
|
|
|
38110
38105
|
{
|
|
38111
38106
|
"id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
|
|
38112
38107
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
38113
|
-
"line":
|
|
38108
|
+
"line": 435,
|
|
38114
38109
|
"title": "an exhausted delivery is dead-lettered, never dropped",
|
|
38115
38110
|
"explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
|
|
38116
38111
|
"citations": [
|
|
@@ -38149,7 +38144,7 @@
|
|
|
38149
38144
|
{
|
|
38150
38145
|
"id": "openwop.it.v2-webhook-durable-delivery.the-dead-letter-read-is-served-and-its-records-carry-no-payload",
|
|
38151
38146
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
38152
|
-
"line":
|
|
38147
|
+
"line": 544,
|
|
38153
38148
|
"title": "the dead-letter read is served and its records carry no payload",
|
|
38154
38149
|
"explicitId": "openwop.requirement.0188.dead-letter-read",
|
|
38155
38150
|
"citations": [
|
|
@@ -38162,7 +38157,7 @@
|
|
|
38162
38157
|
{
|
|
38163
38158
|
"id": "openwop.it.v2-webhook-durable-delivery.after-unregister-the-dead-letter-read-answers-404-not-found-as-for-a-subscriptio",
|
|
38164
38159
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
38165
|
-
"line":
|
|
38160
|
+
"line": 565,
|
|
38166
38161
|
"title": "after unregister, the dead-letter read answers 404 not_found, as for a subscription that never existed",
|
|
38167
38162
|
"explicitId": "openwop.requirement.0217.dead-letter-read-after-unregister",
|
|
38168
38163
|
"citations": [
|
|
@@ -38189,7 +38184,7 @@
|
|
|
38189
38184
|
{
|
|
38190
38185
|
"id": "openwop.it.v2-webhook-durable-delivery.a-dead-letter-record-carries-no-delivered-payload",
|
|
38191
38186
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
38192
|
-
"line":
|
|
38187
|
+
"line": 598,
|
|
38193
38188
|
"title": "a dead-letter record carries no delivered payload",
|
|
38194
38189
|
"explicitId": "openwop.requirement.0188.dead-letter-content-free",
|
|
38195
38190
|
"citations": [
|
|
@@ -38207,7 +38202,7 @@
|
|
|
38207
38202
|
{
|
|
38208
38203
|
"id": "openwop.it.v2-webhook-durable-delivery.every-logged-event-of-a-subscribed-type-reaches-a-healthy-receiver-at-least-once",
|
|
38209
38204
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
38210
|
-
"line":
|
|
38205
|
+
"line": 693,
|
|
38211
38206
|
"title": "every logged event of a subscribed type reaches a healthy receiver at least once within the retry bound",
|
|
38212
38207
|
"explicitId": "openwop.requirement.0173.webhook-delivery-complete",
|
|
38213
38208
|
"citations": [
|
|
@@ -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.45.
|
|
5
|
-
"corpusTag": "v2.45.
|
|
4
|
+
"version": "2.45.29",
|
|
5
|
+
"corpusTag": "v2.45.29",
|
|
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": "13a3eb746eb57dad9ffc605ea4e1e0877a0a9064d44294d0d7df892b042de1b5",
|
|
13
|
+
"api/v2/asyncapi.yaml": "f34721690c724c94a974fdd9e9972e76694900c7a9005d33ec921d3f4e2ff8bf",
|
|
14
|
+
"api/v2/openapi.yaml": "d5e63eafe148c73173da3f5b5668fa44f81bc74682818c57009a5f9c8821912e",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -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": "fcfef02287da21d0337db64fa9f5ddef5a940dac8ae0ce1f92b4bb925af59237",
|
|
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": "d7d2d1ecf668bc5b8ae84b947cc4449f9409d5a5169be97c3740457ee0672dcf",
|
|
219
219
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
220
220
|
"spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
|
|
221
221
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -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": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
|
|
316
|
-
"spec/v2/release.json": "
|
|
316
|
+
"spec/v2/release.json": "d14466e510b339345c3f2be3ceaecb2b85725823e33f71d08a7262c4d0cf47bf",
|
|
317
317
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
318
|
-
"spec/v2/surface-baseline.json": "
|
|
318
|
+
"spec/v2/surface-baseline.json": "5bbf069900b52dd544cae741fef714f4107567eb4b21c4e32a032864feec8a46"
|
|
319
319
|
},
|
|
320
|
-
"corpusCommit": "
|
|
320
|
+
"corpusCommit": "ada50d896fc1edad8046f5d75ae7a5c621d689cd"
|
|
321
321
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose labels on the onward hop, judged from what the suite's A2A peer received
|
|
3
|
+
* (`spec/v2/core/security-defaults.md` §Onward hops; fixture `conformance-purpose-relay`).
|
|
4
|
+
*
|
|
5
|
+
* The scenario labels an inbound A2A message, tags its text with a nonce, and reads
|
|
6
|
+
* the peer's `SendMessage` calls. These functions find the onward message for a nonce
|
|
7
|
+
* and judge it, so a self-test double can show each defect failing its leg.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** One `SendMessage` the peer received: its JSON-RPC body. */
|
|
11
|
+
export interface PeerSend { readonly body: unknown }
|
|
12
|
+
|
|
13
|
+
type Msg = { parts?: unknown; metadata?: { openwop?: { permittedPurposes?: unknown } } };
|
|
14
|
+
|
|
15
|
+
function messageOf(body: unknown): Msg | undefined {
|
|
16
|
+
return (body as { params?: { message?: Msg } } | undefined)?.params?.message;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The text parts of a message body, joined. A2A 1.0 `{ text }` and 0.3 `{ kind: 'text', text }` both count. */
|
|
20
|
+
export function textOf(body: unknown): string {
|
|
21
|
+
const parts = messageOf(body)?.parts;
|
|
22
|
+
if (!Array.isArray(parts)) return '';
|
|
23
|
+
return parts.map((p) => (typeof (p as { text?: unknown })?.text === 'string' ? (p as { text: string }).text : '')).join('');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The onward sends carrying `nonce` in their text: the relay of one inbound message. */
|
|
27
|
+
export function relaysOf(sends: readonly PeerSend[], nonce: string): PeerSend[] {
|
|
28
|
+
return sends.filter((s) => textOf(s.body).includes(nonce));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The label an onward message carries, as sent. */
|
|
32
|
+
export function labelOf(body: unknown): unknown {
|
|
33
|
+
return messageOf(body)?.metadata?.openwop?.permittedPurposes;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type Verdict = { ok: true } | { ok: false; reason: string };
|
|
37
|
+
|
|
38
|
+
/** A received label MUST be re-emitted on the onward message, narrowing and never widening. */
|
|
39
|
+
export function judgeReemit(received: readonly string[], onward: unknown): Verdict {
|
|
40
|
+
if (!Array.isArray(onward)) return { ok: false, reason: `the onward message carries no metadata.openwop.permittedPurposes (got ${JSON.stringify(onward)})` };
|
|
41
|
+
const widened = onward.map(String).filter((p) => !received.includes(p));
|
|
42
|
+
if (widened.length > 0) return { ok: false, reason: `the onward label widens the received one ${JSON.stringify(received)}: it adds ${JSON.stringify(widened)}` };
|
|
43
|
+
return { ok: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `[]` means no onward use. `controlRelays` is what an unlabelled control message
|
|
48
|
+
* produced: with none, a missing relay under `[]` proves nothing (`blocked`).
|
|
49
|
+
*/
|
|
50
|
+
export function judgeEmpty(controlRelays: number, emptyRelays: number): Verdict | 'blocked' {
|
|
51
|
+
if (controlRelays === 0) return 'blocked';
|
|
52
|
+
if (emptyRelays > 0) return { ok: false, reason: `the peer received ${emptyRelays} message(s) relaying the []-labelled data` };
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
@@ -38,7 +38,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
|
38
38
|
import { softSkip } from '../lib/soft-skip.js';
|
|
39
39
|
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
40
40
|
import { req } from '../lib/requirement-ids.js';
|
|
41
|
-
import { SEAMS_PREFIX } from '../lib/seams.js';
|
|
41
|
+
import { SEAMS_PREFIX, seamsProfileAdvertised } from '../lib/seams.js';
|
|
42
42
|
import { unservedDestination } from '../lib/scoped-receiver.js';
|
|
43
43
|
import { errorInfos, normaliseErrorData, isOpenwopEnvelope, A2A_ERROR_DOMAIN } from '../lib/a2a-error-info.js';
|
|
44
44
|
|
|
@@ -513,6 +513,7 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
|
|
|
513
513
|
const oauth = await familyAdvertised('oauth');
|
|
514
514
|
if (oauth?.['credentialInterrupt'] !== true) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised — the host raises no credential interrupt to project (RFC 0199 §C.1)');
|
|
515
515
|
if (!isFixtureAdvertised('conformance-credential')) return softSkip('blocked', 'fixture conformance-credential is not in the advertised fixtures[]');
|
|
516
|
+
if (!seamsProfileAdvertised(await v2Discovery().catch(() => null))) return softSkip('inapplicable', 'a fresh Subject is minted through the seams profile (credential mint seam) — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
516
517
|
// A fresh Subject, so no credential another scenario acquired satisfies the node.
|
|
517
518
|
const minted = await driver.post(`${SEAMS_PREFIX}/sample/auth/credential/mint`, { lane: 'api-key' }).catch(() => null);
|
|
518
519
|
const bearer = (minted?.json as { credential?: unknown } | undefined)?.credential;
|
|
@@ -34,7 +34,7 @@ import { loadEnv } from '../lib/env.js';
|
|
|
34
34
|
import { softSkip } from '../lib/soft-skip.js';
|
|
35
35
|
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
36
36
|
import { req } from '../lib/requirement-ids.js';
|
|
37
|
-
import { SEAMS_PREFIX } from '../lib/seams.js';
|
|
37
|
+
import { SEAMS_PREFIX, seamsProfileAdvertised } from '../lib/seams.js';
|
|
38
38
|
|
|
39
39
|
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the MCP client: every leg POSTs JSON-RPC to the mount the host advertises in mcp.serverUrls; nothing harness-hosted is handed to the host';
|
|
40
40
|
|
|
@@ -308,6 +308,7 @@ describe('RFC 0208 — v2-mcp-mount-map (host as MCP 2026-07-28 server, gated on
|
|
|
308
308
|
if (oauth?.['credentialInterrupt'] !== true) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised — the host raises no credential interrupt to bridge (RFC 0199 §C.1)');
|
|
309
309
|
const missing = needFixtures(['conformance-credential']);
|
|
310
310
|
if (missing) return softSkip('blocked', missing);
|
|
311
|
+
if (!seamsProfileAdvertised(await v2Discovery().catch(() => null))) return softSkip('inapplicable', 'a fresh Subject is minted through the seams profile (credential mint seam) — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
311
312
|
const minted = await driver.post(`${SEAMS_PREFIX}/sample/auth/credential/mint`, { lane: 'api-key' }).catch(() => null);
|
|
312
313
|
const bearer = (minted?.json as { credential?: unknown } | undefined)?.credential;
|
|
313
314
|
if (typeof bearer !== 'string') return softSkip('blocked', 'the credential mint seam did not mint a fresh Subject — a Subject that may already hold a credential makes the leg vacuous');
|
|
@@ -1,41 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* v2 — purpose labels on the onward hop (`spec/v2/core/security-defaults.md`
|
|
3
|
-
* §Onward hops; suite 2.45.23, target major 2;
|
|
3
|
+
* §Onward hops; suite 2.45.23, target major 2; relay path suite 2.45.29).
|
|
4
4
|
*
|
|
5
5
|
* A host advertising `purposePropagation` MUST re-emit a `permittedPurposes`
|
|
6
6
|
* label it received on every onward hop of the same data, narrowing and never
|
|
7
|
-
* widening, and MUST treat `[]` as no onward use. The
|
|
8
|
-
* self-reported forward seam; this file observes a real onward hop instead.
|
|
9
|
-
* The suite plays both ends: it hands the host a label through the §22 `invoke`
|
|
10
|
-
* seam (`permittedPurposes`), and its fake A2A peer receives what the host's
|
|
11
|
-
* production A2A client actually sends.
|
|
7
|
+
* widening, and MUST treat `[]` as no onward use. The suite plays both ends.
|
|
12
8
|
*
|
|
13
|
-
*
|
|
14
|
-
* `metadata.openwop.permittedPurposes`, a subset of
|
|
15
|
-
* the label sent;
|
|
16
|
-
* empty-no-onward with `[]` the host makes no onward call, while an
|
|
17
|
-
* unlabelled control call through the same seam
|
|
18
|
-
* reaches the peer (non-arrival is evidence, not a
|
|
19
|
-
* dead pipe).
|
|
9
|
+
* Two ways in, the first preferred:
|
|
20
10
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
11
|
+
* relay normative operations only. The suite sends an A2A `SendMessage`,
|
|
12
|
+
* labelled with `metadata.openwop.permittedPurposes`, to the agent whose
|
|
13
|
+
* card routes the fixture `conformance-purpose-relay` (found through
|
|
14
|
+
* `GET /agents` and `GetExtendedAgentCard`, `interop.md` §Per-agent
|
|
15
|
+
* cards). The fixture relays the message's text to the suite's shared
|
|
16
|
+
* fake A2A peer, which reads the onward label. A nonce in the text
|
|
17
|
+
* matches each onward message to its inbound one.
|
|
18
|
+
* seam the §22 `invoke` seam takes `permittedPurposes` and a peer URL.
|
|
19
|
+
*
|
|
20
|
+
* onward-reemit-no-widen the onward message carries the label, a subset of
|
|
21
|
+
* the one received;
|
|
22
|
+
* empty-no-onward with `[]` nothing reaches the peer, while an
|
|
23
|
+
* unlabelled control message does (non-arrival is
|
|
24
|
+
* evidence, not a dead pipe).
|
|
25
|
+
*
|
|
26
|
+
* Not here: a merge of labelled inputs carrying their intersection is RFC 0128
|
|
27
|
+
* §3 text that v2 prose does not state.
|
|
23
28
|
*
|
|
24
29
|
* Dispositions: discovery unreadable ⇒ `blocked`; `purposePropagation` absent,
|
|
25
|
-
* `propagatesOnward` false
|
|
26
|
-
*
|
|
30
|
+
* `propagatesOnward` false or `a2a` absent ⇒ `inapplicable`; neither the relay
|
|
31
|
+
* (`a2a-1.0` + `agentCards` + the fixture advertised) nor the seams profile ⇒
|
|
32
|
+
* `inapplicable`; the fixture advertised but no card routing it, or the suite's
|
|
33
|
+
* fake peer not running ⇒ `blocked`; seam unwired ⇒ `seamAbsent`. Each note names
|
|
34
|
+
* the path used.
|
|
27
35
|
*
|
|
28
36
|
* @see spec/v2/core/security-defaults.md §Onward hops
|
|
37
|
+
* @see conformance/fixtures.md §conformance-purpose-relay
|
|
29
38
|
* @see spec/v1/host-sample-test-seams.md §22
|
|
30
39
|
*/
|
|
31
40
|
|
|
32
41
|
import { describe, it, expect } from 'vitest';
|
|
42
|
+
import { randomBytes } from 'node:crypto';
|
|
33
43
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
34
44
|
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
35
45
|
import { SEAMS_PREFIX, seamsProfileAdvertised } from '../lib/seams.js';
|
|
46
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
36
47
|
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
37
48
|
import { req } from '../lib/requirement-ids.js';
|
|
38
|
-
import { A2AFakePeer, type A2APeerInvocation } from '../lib/a2a-fake-peer.js';
|
|
49
|
+
import { A2AFakePeer, getA2AFakePeer, type A2APeerInvocation } from '../lib/a2a-fake-peer.js';
|
|
50
|
+
import { relaysOf, labelOf, judgeReemit, judgeEmpty, type PeerSend } from '../lib/purpose-relay-witness.js';
|
|
39
51
|
|
|
40
52
|
export const REQUIRES_HOST_CALLBACK = "the host's A2A client calls the suite's fake A2A peer";
|
|
41
53
|
|
|
@@ -43,6 +55,7 @@ const DOC = 'spec/v2/core/security-defaults.md §Onward hops';
|
|
|
43
55
|
const ID_REEMIT = 'openwop.requirement.purpose-propagation.onward-reemit-no-widen';
|
|
44
56
|
const ID_EMPTY = 'openwop.requirement.purpose-propagation.empty-no-onward';
|
|
45
57
|
const SEAM = `${SEAMS_PREFIX}/sample/a2a/invoke`;
|
|
58
|
+
const RELAY = 'conformance-purpose-relay';
|
|
46
59
|
const LABEL = ['openwop-conformance-analytics', 'openwop-conformance-support'];
|
|
47
60
|
|
|
48
61
|
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
@@ -52,68 +65,120 @@ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse
|
|
|
52
65
|
type Skip = { kind: 'blocked' | 'inapplicable' | 'seam'; reason: string };
|
|
53
66
|
const skip = (s: Skip): undefined => (s.kind === 'seam' ? seamAbsent(s.reason) : softSkip(s.kind, s.reason));
|
|
54
67
|
|
|
55
|
-
|
|
68
|
+
/** The relay agent: the host's JSONRPC 1.0 interface and the agent's routing value. */
|
|
69
|
+
interface Relay { kind: 'relay'; rpcUrl: string; tenant: string; peer: A2AFakePeer }
|
|
70
|
+
type Path = Relay | { kind: 'seam' };
|
|
71
|
+
|
|
72
|
+
async function rpc(url: string, method: string, params: unknown): Promise<{ status: number; result?: Record<string, unknown>; error?: unknown } | null> {
|
|
73
|
+
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'A2A-Version': '1.0' };
|
|
74
|
+
const key = process.env['OPENWOP_API_KEY'];
|
|
75
|
+
if (key) headers['authorization'] = `Bearer ${key}`;
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: randomBytes(4).readUInt32BE(0), method, params }) });
|
|
78
|
+
const body = (await res.json().catch(() => ({}))) as { result?: Record<string, unknown>; error?: unknown };
|
|
79
|
+
return { status: res.status, ...(body.result !== undefined ? { result: body.result } : {}), ...(body.error !== undefined ? { error: body.error } : {}) };
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Find the agent whose card routes the relay fixture; a Skip when the relay cannot be driven. */
|
|
86
|
+
async function relayPath(a2a: Record<string, unknown>): Promise<Relay | Skip> {
|
|
87
|
+
const peer = getA2AFakePeer();
|
|
88
|
+
if (peer === null) return { kind: 'blocked', reason: `relay path: the host advertises ${RELAY}, whose onward peer is the suite's fake A2A peer, and OPENWOP_A2A_FAKE_PEER is not enabled` };
|
|
89
|
+
const cardUrl = a2a['agentCardUrl'];
|
|
90
|
+
if (typeof cardUrl !== 'string') return { kind: 'blocked', reason: 'relay path: a2a.agentCardUrl is not advertised' };
|
|
91
|
+
const card = await fetch(cardUrl, { headers: { accept: 'application/json', 'A2A-Version': '1.0' } }).then((r) => r.json() as Promise<{ supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }> }>).catch(() => null);
|
|
92
|
+
const rpcUrl = card?.supportedInterfaces?.find((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0')?.url;
|
|
93
|
+
if (typeof rpcUrl !== 'string') return { kind: 'blocked', reason: `relay path: the public card at ${cardUrl} lists no JSONRPC 1.0 interface` };
|
|
94
|
+
const inv = await http(() => driver.get('/agents'));
|
|
95
|
+
const agents = inv?.status === 200 ? ((inv.json as { agents?: Array<{ a2aTenant?: unknown }> } | undefined)?.agents ?? []) : [];
|
|
96
|
+
for (const a of agents) {
|
|
97
|
+
if (typeof a.a2aTenant !== 'string') continue;
|
|
98
|
+
const c = await rpc(rpcUrl, 'GetExtendedAgentCard', { tenant: a.a2aTenant });
|
|
99
|
+
const skills = (c?.result?.['skills'] as Array<{ id?: unknown }> | undefined) ?? [];
|
|
100
|
+
if (skills.length === 1 && skills[0]?.id === RELAY) return { kind: 'relay', rpcUrl, tenant: a.a2aTenant, peer };
|
|
101
|
+
}
|
|
102
|
+
return { kind: 'blocked', reason: `relay path: the host advertises ${RELAY}, but no agent in the caller's GET /agents has a card whose only skill is ${RELAY}` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function gate(): Promise<Path | Skip> {
|
|
56
106
|
let doc: Record<string, unknown> | null;
|
|
57
107
|
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
58
108
|
if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
59
109
|
const pp = await familyAdvertised('purposePropagation');
|
|
60
110
|
if (pp === null) return { kind: 'inapplicable', reason: 'the host does not advertise purposePropagation' };
|
|
61
111
|
if (pp['propagatesOnward'] === false) return { kind: 'inapplicable', reason: 'purposePropagation.propagatesOnward is false — the host has no onward hop' };
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
112
|
+
const a2a = await familyAdvertised('a2a');
|
|
113
|
+
if (a2a === null) return { kind: 'inapplicable', reason: 'a2a not advertised — the onward hop observed here is the host A2A client' };
|
|
114
|
+
const profiles = Array.isArray(a2a['profiles']) ? (a2a['profiles'] as unknown[]) : [];
|
|
115
|
+
if (profiles.includes('a2a-1.0') && a2a['agentCards'] === true && isFixtureAdvertised(RELAY)) return relayPath(a2a);
|
|
116
|
+
if (seamsProfileAdvertised(doc)) return { kind: 'seam' };
|
|
117
|
+
return { kind: 'inapplicable', reason: `neither path is offered: the relay needs the a2a-1.0 profile, a2a.agentCards and the ${RELAY} fixture; the §22 invoke seam needs the seams profile` };
|
|
65
118
|
}
|
|
66
119
|
|
|
67
120
|
/** The message sends the peer received. */
|
|
68
121
|
const sends = (peer: A2AFakePeer): A2APeerInvocation[] => peer.invocations().filter((i) => i.rpcMethod === 'SendMessage' || i.rpcMethod === 'message/send');
|
|
69
122
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Cause one onward hop of `nonce`, labelled or not. Relay: an inbound SendMessage,
|
|
125
|
+
* which blocks until the run is terminal (interop-map a2a SendMessage). Seam: §22.
|
|
126
|
+
* Returns the onward sends carrying the nonce, or a Skip.
|
|
127
|
+
*/
|
|
128
|
+
async function cause(path: Path, peer: A2AFakePeer, nonce: string, permittedPurposes?: string[]): Promise<PeerSend[] | Skip> {
|
|
129
|
+
if (path.kind === 'relay') {
|
|
130
|
+
const message = { messageId: `purpose-${nonce}`, role: 'ROLE_USER', parts: [{ text: `relay ${nonce}` }], ...(permittedPurposes ? { metadata: { openwop: { permittedPurposes } } } : {}) };
|
|
131
|
+
const r = await rpc(path.rpcUrl, 'SendMessage', { tenant: path.tenant, message });
|
|
132
|
+
if (r === null) return { kind: 'blocked', reason: `relay path: SendMessage to ${path.rpcUrl} failed to connect` };
|
|
133
|
+
if (r.status >= 400 && permittedPurposes?.length !== 0) return { kind: 'blocked', reason: `relay path: SendMessage answered HTTP ${r.status}` };
|
|
134
|
+
} else {
|
|
135
|
+
const res = await http(() => driver.post(SEAM, { peerUrl: peer.hostFacingEndpoint(), ...(permittedPurposes ? { permittedPurposes } : {}) }));
|
|
136
|
+
if (res === null) return { kind: 'blocked', reason: `${SEAM} unreachable (fetch failed)` };
|
|
137
|
+
if (res.status === 404 || res.status === 405) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — host-sample-test-seams.md §22` };
|
|
138
|
+
// The seam's onward message carries no nonce: every send after the call is its relay.
|
|
139
|
+
return sends(peer).map((i) => ({ body: i.body }));
|
|
140
|
+
}
|
|
141
|
+
return relaysOf(sends(peer).map((i) => ({ body: i.body })), nonce);
|
|
73
142
|
}
|
|
74
143
|
|
|
75
|
-
|
|
76
|
-
const res = await http(() => driver.post(SEAM, { peerUrl: peer.hostFacingEndpoint(), ...(permittedPurposes ? { permittedPurposes } : {}) }));
|
|
77
|
-
if (res === null) return { kind: 'blocked', reason: `${SEAM} unreachable (fetch failed)` };
|
|
78
|
-
if (res.status === 404 || res.status === 405) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — host-sample-test-seams.md §22` };
|
|
79
|
-
return res;
|
|
80
|
-
}
|
|
144
|
+
const nonce = (): string => `n-${randomBytes(6).toString('hex')}`;
|
|
81
145
|
|
|
82
|
-
describe('v2 purpose labels on the onward hop (security-defaults.md §Onward hops
|
|
146
|
+
describe('v2 purpose labels on the onward hop (security-defaults.md §Onward hops)', () => {
|
|
83
147
|
it('a received label is re-emitted on the onward message, never widened', async () => {
|
|
84
148
|
const g = await gate();
|
|
85
|
-
if (g) return skip(g);
|
|
86
|
-
const peer = new A2AFakePeer({ protocolVersions: ['1.0'] });
|
|
87
|
-
await peer.start();
|
|
149
|
+
if ('reason' in g) return skip(g);
|
|
150
|
+
const peer = g.kind === 'relay' ? g.peer : new A2AFakePeer({ protocolVersions: ['1.0'] });
|
|
151
|
+
if (g.kind === 'seam') await peer.start();
|
|
88
152
|
try {
|
|
89
|
-
const
|
|
90
|
-
if (!(
|
|
91
|
-
const sent =
|
|
92
|
-
if (sent === undefined) return softSkip('blocked',
|
|
93
|
-
const
|
|
94
|
-
expect(
|
|
95
|
-
const widened = (Array.isArray(label) ? label : []).filter((p) => !LABEL.includes(String(p)));
|
|
96
|
-
expect(widened, req(ID_REEMIT, DOC, `the onward label MUST NOT widen the received one (${JSON.stringify(LABEL)}); it added ${JSON.stringify(widened)}`)).toEqual([]);
|
|
153
|
+
const relayed = await cause(g, peer, nonce(), LABEL);
|
|
154
|
+
if (!Array.isArray(relayed)) return skip(relayed);
|
|
155
|
+
const sent = relayed[0];
|
|
156
|
+
if (sent === undefined) return softSkip('blocked', `${g.kind} path: the labelled data never reached the suite peer, so there is no onward message to read`);
|
|
157
|
+
const v = judgeReemit(LABEL, labelOf(sent.body));
|
|
158
|
+
expect(v.ok ? '' : v.reason, req(ID_REEMIT, DOC, `${g.kind} path: the onward message MUST carry the received label as metadata.openwop.permittedPurposes, never widened`)).toBe('');
|
|
97
159
|
} finally {
|
|
98
|
-
await peer.stop();
|
|
160
|
+
if (g.kind === 'seam') await peer.stop();
|
|
99
161
|
}
|
|
100
162
|
});
|
|
101
163
|
|
|
102
164
|
it('an empty label means no onward call, while an unlabelled control call goes through', async () => {
|
|
103
165
|
const g = await gate();
|
|
104
|
-
if (g) return skip(g);
|
|
105
|
-
const peer = new A2AFakePeer({ protocolVersions: ['1.0'] });
|
|
106
|
-
await peer.start();
|
|
166
|
+
if ('reason' in g) return skip(g);
|
|
167
|
+
const peer = g.kind === 'relay' ? g.peer : new A2AFakePeer({ protocolVersions: ['1.0'] });
|
|
168
|
+
if (g.kind === 'seam') await peer.start();
|
|
107
169
|
try {
|
|
108
|
-
const control = await
|
|
109
|
-
if (!(
|
|
170
|
+
const control = await cause(g, peer, nonce());
|
|
171
|
+
if (!Array.isArray(control)) return skip(control);
|
|
110
172
|
const before = sends(peer).length;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
173
|
+
const empty = await cause(g, peer, nonce(), []);
|
|
174
|
+
if (!Array.isArray(empty)) return skip(empty);
|
|
175
|
+
// The seam's sends carry no nonce: count what arrived after the control.
|
|
176
|
+
const emptyRelays = g.kind === 'seam' ? sends(peer).length - before : empty.length;
|
|
177
|
+
const v = judgeEmpty(control.length, emptyRelays);
|
|
178
|
+
if (v === 'blocked') return softSkip('blocked', `${g.kind} path: the unlabelled control message reached no peer, so a missing call under [] would prove nothing`);
|
|
179
|
+
expect(v.ok ? '' : v.reason, req(ID_EMPTY, DOC, `${g.kind} path: a host MUST treat [] as no onward use`)).toBe('');
|
|
115
180
|
} finally {
|
|
116
|
-
await peer.stop();
|
|
181
|
+
if (g.kind === 'seam') await peer.stop();
|
|
117
182
|
}
|
|
118
183
|
});
|
|
119
184
|
});
|
|
@@ -403,18 +403,23 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
403
403
|
// Unfailable-leg audit (2026-09-26): `> 0 ms` alone let a host advertising
|
|
404
404
|
// `backoff: exponential` that retried in a tight loop (any constant
|
|
405
405
|
// spacing, even a few ms) pass. An exponential backoff MUST widen, so with
|
|
406
|
-
// three or more attempts the second gap is
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
406
|
+
// three or more attempts the second gap is wider than the first by at least
|
|
407
|
+
// a quarter of it. Not 1.5x: each gap also carries a constant c, the
|
|
408
|
+
// receiver round trip a host waits on before scheduling the retry, so the
|
|
409
|
+
// gaps are B + c and 2B + c, and a public cut's tunnel (c ≈ 270 ms
|
|
410
|
+
// against a 500 ms base, 2026-10-08) put a correct host at 1.43x. A
|
|
411
|
+
// constant spacing still fails: its gaps differ only by jitter. Only the
|
|
412
|
+
// first two gaps are compared, so a host whose schedule hits its cap
|
|
413
|
+
// later is not convicted. `fixed` gets no equivalent: its base interval
|
|
414
|
+
// is not on the wire (see the comment above the `!retried` branch), so
|
|
415
|
+
// there is no floor to measure against.
|
|
411
416
|
if (policy.backoff === 'exponential' && times.length >= 3) {
|
|
412
417
|
const gap1 = times[1]! - times[0]!;
|
|
413
418
|
const gap2 = times[2]! - times[1]!;
|
|
414
419
|
expect(
|
|
415
|
-
gap2,
|
|
416
|
-
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `retry attempts for ${key} MUST follow the advertised exponential backoff — the second gap (${gap2}ms) MUST
|
|
417
|
-
).toBeGreaterThanOrEqual(
|
|
420
|
+
gap2 - gap1,
|
|
421
|
+
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `retry attempts for ${key} MUST follow the advertised exponential backoff — the second gap (${gap2}ms) MUST exceed the first (${gap1}ms) by at least a quarter of it; a constant spacing is not exponential`),
|
|
422
|
+
).toBeGreaterThanOrEqual(0.25 * gap1);
|
|
418
423
|
}
|
|
419
424
|
}
|
|
420
425
|
} else {
|