@openwop/openwop-conformance 2.36.0 → 2.37.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.
@@ -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": 553,
4
+ "files": 557,
5
5
  "v1": 448,
6
- "v2": 113
6
+ "v2": 117
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1250,6 +1250,9 @@
1250
1250
  "v2-a2a-agent-cards.test.ts": [
1251
1251
  2
1252
1252
  ],
1253
+ "v2-a2a-client-error-details.test.ts": [
1254
+ 2
1255
+ ],
1253
1256
  "v2-a2a-operation-map.test.ts": [
1254
1257
  2
1255
1258
  ],
@@ -1364,6 +1367,9 @@
1364
1367
  "v2-id-grammar.test.ts": [
1365
1368
  2
1366
1369
  ],
1370
+ "v2-idempotency-in-flight.test.ts": [
1371
+ 2
1372
+ ],
1367
1373
  "v2-idempotency-key-grammar.test.ts": [
1368
1374
  2
1369
1375
  ],
@@ -1373,6 +1379,9 @@
1373
1379
  "v2-interop-trace-context.test.ts": [
1374
1380
  2
1375
1381
  ],
1382
+ "v2-interrupt-resolve-terminal.test.ts": [
1383
+ 2
1384
+ ],
1376
1385
  "v2-interrupt-token-scheme.test.ts": [
1377
1386
  2
1378
1387
  ],
@@ -1508,6 +1517,9 @@
1508
1517
  "v2-run-snapshot-etag.test.ts": [
1509
1518
  2
1510
1519
  ],
1520
+ "v2-sse-last-event-id-cursor.test.ts": [
1521
+ 2
1522
+ ],
1511
1523
  "v2-sse-last-event-id.test.ts": [
1512
1524
  2
1513
1525
  ],
@@ -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.36.0",
5
- "corpusTag": "v2.36.0",
4
+ "version": "2.37.0",
5
+ "corpusTag": "v2.37.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
- "api/openapi.yaml": "d19c3d4af7fd0e2305dd5fee3d5b984c6d0e9b1824d72d8dffc714a92b248269",
10
+ "api/openapi.yaml": "df108919a368e0a593f8f3dc8e2150a07d394e8971e1f91a3d6301e2e19cb132",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "591e917af3c0e79fbe831690b35383bcc6976dd2e0ef0660c5e3bce5541dc9c2",
13
- "api/v2/asyncapi.yaml": "813a73c8c1e25c55cb60d92aecf2fc1361ab84894c4e15a378c49d11dd0f1984",
14
- "api/v2/openapi.yaml": "e5156f2a6d241cec8bb4f5dd872400b4bc021dff61e572ab4b11c52105d18f22",
12
+ "api/seams-v2.yaml": "e60b9ff834b83ebe710a6960a964296186c529b67453c11c263a78dc83e76fe3",
13
+ "api/v2/asyncapi.yaml": "3f584bb004c0273a20cadf8a3a2fdfd219d1851734faec5c99b0d69022ada529",
14
+ "api/v2/openapi.yaml": "a0880db46930a58b99c860fe04f17b6b782d31a4cb4f86398446adbf36c062fe",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -116,8 +116,8 @@
116
116
  "schemas/v2/artifact.schema.json": "85b9dc284d39b99d2a66fa8cbf47f2dd3d8526d3bd6cc411ad315b372de38975",
117
117
  "schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
118
118
  "schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
119
- "schemas/v2/capabilities.schema.json": "0b895f892c4f29317782a81dd2c6ca6f90de972d99248d65d5eb0c3ab96e1b5d",
120
- "schemas/v2/certification-bundle.schema.json": "9caf701cf991644aeb3a95beedd4acdef0b3fb351c41b0eaaa804e658af75642",
119
+ "schemas/v2/capabilities.schema.json": "79ddbbda4686fe66ec5b0aaf9bafb2385e2f8ccbb1ee39087d75cef9fd1f1628",
120
+ "schemas/v2/certification-bundle.schema.json": "168a1e79097a755f6da57e4671586f4f2ddfaba26cb4ec2d5de3774517f4ac64",
121
121
  "schemas/v2/channel-presence-payload.schema.json": "4eeb224910e6f7eee3ab5e7c3899559c25c83a26d0022e0ad195c4672611977a",
122
122
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
123
123
  "schemas/v2/chat-card-pack-manifest.schema.json": "f7cf09b30d3e1d2251620d84ea9f65541438d015ab44aec10d1dcfe68d8bd496",
@@ -204,38 +204,38 @@
204
204
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
205
  "spec/v1/alias-detectors.json": "2401fcb1c18cdd688c018b3d716ae6bca85c5e356220ee2b793bd9d81872412d",
206
206
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "f08b1cd57e065fe1f10499ce2b3452d326dc70c3f64fbc68d2795ca16103d7a6",
207
+ "spec/v1/core-standard-manifest.json": "d624b312258b0299906ed0ace1d6d79e6df4a0a2e5543d55aef06ff81e434da1",
208
208
  "spec/v1/deprecations.json": "2d03f4729810280147ea08c630ea65434f2dc5370567d0aac41c3595f8337d40",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
211
211
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
212
212
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
213
- "spec/v1/gaps.json": "3002e1a77b9011de98e6e1178fbb934059794b0bcecd09519ea127331ae99073",
213
+ "spec/v1/gaps.json": "c20fe027514c8778b34c8fadde8bc7d1db5c4034638d76d297cc89a5bcae7ba2",
214
214
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
215
215
  "spec/v1/migrations.json": "8839497d6a4830b53fa7867014a8336f0d725ef2346352e54fb580b7947897af",
216
216
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
217
- "spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
218
- "spec/v1/spec-gaps.json": "eb3bfcb9c7d05c9a6845d4a40ec9493cfaa5a671932daf1af561310136fdabe8",
217
+ "spec/v1/operation-path-manifest.json": "968c2fceb27cf76acbb14f7d250798f913bbbd3e9789dd3fb2a32c1ef536e771",
218
+ "spec/v1/spec-gaps.json": "ea9612af55bea9dae3e82223d4f714243f8f590b0193f1188257b13491a38afc",
219
219
  "spec/v2/README.md": "ccfed7d978ce7293a6d8f3c0f6045835dfb7e488f2b67a68f6b04d67c4b0cfc9",
220
220
  "spec/v2/core/capabilities.md": "e22381fed3a93376ad478fa7f07571fdad9653aefd7c05f189a2ef45a49f6c63",
221
- "spec/v2/core/conformance.md": "4998af6d64dd6073a396715b5d91476304041dc1493a82885c6c91f098e28e81",
221
+ "spec/v2/core/conformance.md": "975bf95049247530d723e81051b6e64bd2a1f9d150183dec391cae48e35af7cc",
222
222
  "spec/v2/core/connection-packs.md": "7257339a4fc125011bf89961588c949cb058a0c0f7d9808ad08a5288deb3f8fc",
223
223
  "spec/v2/core/conversation.md": "c9a937e410fdb4e092cec2f05a7cab8b5232745090cfb20041c03c70ccbb5cdc",
224
- "spec/v2/core/errors.md": "0210177647748efc5fe9affe29a9029152c4f983248ca88c6375601d7e636591",
225
- "spec/v2/core/events.md": "ee0f7d2cff5bfa29c38b77f73269d7d755b8036cb1a5c7b08f02de92c54f3490",
224
+ "spec/v2/core/errors.md": "619cd74381c41acf33d012eb1afd834e148ac292663a1a6fa6744dbaff9661d9",
225
+ "spec/v2/core/events.md": "3df1866a89fb765c78adf79b8d51f96c59584dee9aa88d59179b88a681d827da",
226
226
  "spec/v2/core/form-content-packs.md": "55ea021b5f676f5851f420b3597262fbf6642108bddeb726922f8c5dd96c968b",
227
- "spec/v2/core/headers.md": "c0e954d08e777e43816fbbabca61f303b78db09596b878d818b7a5022a1b6e7c",
227
+ "spec/v2/core/headers.md": "38c03524e3aeea30862432d203faffb8b0b05ec65136d223e96ab06941c3717b",
228
228
  "spec/v2/core/host-services.md": "c3ff93201d642edf835e9ae84f06a17edd207f9d351f8bfd70572be05bbfd894",
229
- "spec/v2/core/idempotency.md": "a8492ec66569c00f6f559b75b80d93adbbc0e964a24e8dfedfe4057e47d69a9f",
229
+ "spec/v2/core/idempotency.md": "b61d4a200526485937897dd7ebca092e68424a80624e0be7a0b7db8cdbd54403",
230
230
  "spec/v2/core/identity.md": "d15667552019f64316be9b65c6e5696c10766581405f05da3907fb47f36e6459",
231
- "spec/v2/core/interop.md": "5b7d2742eef309b1590b8d6690e5435b00c2ce6b395284684e3591cb9ed47bab",
232
- "spec/v2/core/interrupt.md": "e0199472da7d0412721ab487a07c82fe78cbc9b60612bfcfc3cb876dc1a8c96f",
231
+ "spec/v2/core/interop.md": "ec8239d6d430e0d6df4db51d23bcbbbf9bc989bdb7e487e8312a104485ab6d16",
232
+ "spec/v2/core/interrupt.md": "1dc94f7c791dd5a547b1abafde6a8a76acd3d2993d288057f302f57911dc83bf",
233
233
  "spec/v2/core/node-pack-runtimes.md": "c435ad506f618831b6c0a9fd9150d83a5dc6caa1e3d755b949af2ff8949f1140",
234
234
  "spec/v2/core/oauth.md": "08e558a742259e75cfb2123f4c364d238dc4c57eea1a3c25d8f420ab4dff8244",
235
235
  "spec/v2/core/overview.md": "8f9d4a68b6185664e40d226cb82d9a9270a6c6630e3db59bddff300be781947d",
236
236
  "spec/v2/core/packs.md": "3107db14f68f36f5c0d8a13a6f0b3ca9ebef7ff890b8133894bfd28a63462d9b",
237
237
  "spec/v2/core/persistence.md": "d32736bd74e51df7c552565b4105a8ec801ece6d2292c8676421368ee9bffec1",
238
- "spec/v2/core/replay.md": "1166ea8dc73f825b3fd1ef083085eea72cc2a2663b5939a61d1c69d8466991b3",
238
+ "spec/v2/core/replay.md": "c0412290c805231f5b975acc0c094465c627cd600abec34efa034cbf0a4520cf",
239
239
  "spec/v2/core/runs.md": "c7dcc5116648f42cadecdcd4c949b194272908cbc4b1fbc7321956d5b3a713af",
240
240
  "spec/v2/core/security-defaults.md": "b54c055bea0a3c5417751a7423e390ef59b5f9acd6911f1b4b754e1a4578e7e2",
241
241
  "spec/v2/core/tool-catalog.md": "35f1a3fd509db0fc0490ddf400686fe94dc4b20c98064d5ebe776da516153e60",
@@ -246,7 +246,7 @@
246
246
  "spec/v2/corrections.schema.json": "4ac595b9a6d7f66d53de03fe0edbe0dc58de46426932387bfdc0ceb3cf949a3e",
247
247
  "spec/v2/declaration.json": "84bd247804e625b0a5d66f2011726626e8a9b21e55c479cc9ba239e6b5c358a6",
248
248
  "spec/v2/declaration.schema.json": "881c08b455aa12c41a0a0ec43a960ab78b75ff1302188f7eaaa43074ed4cff7a",
249
- "spec/v2/errors.json": "769e77369aa9bae7ba9c3728fc8ce04795d20ca4baa2a22056b70d0f5a2c4801",
249
+ "spec/v2/errors.json": "287a8bb487be96a2938b2101dd737ee87336440b3777a37dd44954726ed891b1",
250
250
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
251
251
  "spec/v2/event-codemap.schema.json": "b173f2a9bcc0bf9b62a474ce3b91e1431d597e64fd1560fc58662cd8606eaf9c",
252
252
  "spec/v2/ext/README.md": "ff91a503e0795e1b898e37d9e445add2d5975529723f7b32b2599b60a24c1ae3",
@@ -273,7 +273,7 @@
273
273
  "spec/v2/facets/auth.schema.json": "668f183fc15a6fb058c53635d3457db5a821f551a90141d08676a7a28917b0a9",
274
274
  "spec/v2/facets/envelopeStrictness.schema.json": "28be0a27f880bd45ec1222a16155f5e9167bbca57bf0322970fe1eb2654562aa",
275
275
  "spec/v2/facets/heartbeat.schema.json": "81d7fa1895a8b0241bd07cf00a7e7905bef0fd14eb3427325261c62eee5746a6",
276
- "spec/v2/facets/interrupt.schema.json": "3cc228b9bee48922b22bd6b0bee1a3244dc83d377a616cfb523435368da11ebb",
276
+ "spec/v2/facets/interrupt.schema.json": "2110ef32c3f880bf706475ef4cf8ddf97ff06871b8e6003a5edbcf4439f1fb70",
277
277
  "spec/v2/facets/mcp.schema.json": "7060657dfcff170f079beed160e3db7c1d3cc4410cd3cf64863baf6459f781cd",
278
278
  "spec/v2/facets/oauth.schema.json": "3a0cbe249d3bd5a5982349ae7ba2534776cc958e4a57e0fbeabf2bdab6e99e69",
279
279
  "spec/v2/facets/packs.schema.json": "d544a280419c0052ce0f3c3b378f7aa04085cadc31df52a3c8f43da600b131b1",
@@ -285,16 +285,16 @@
285
285
  "spec/v2/facets/webhooks.schema.json": "2fac635ee5fbc10a14a1a46cf512a11bac660bfbabe0aea79f6c7b657ecb9e4e",
286
286
  "spec/v2/facets/workflowChainPacks.schema.json": "ea625ce818fbeb746478f00f31f96ee9613d538e4409c258c1bfb517acdeaba9",
287
287
  "spec/v2/id-field-bindings.json": "9f6fd280250447e26a6704811c352bb8f2ff6c4bd242befe3da0ba38962370e9",
288
- "spec/v2/interop-map.json": "245bae77503ceba332436ff48054c4de63a414dc02ada4bc3f083740615c6986",
289
- "spec/v2/interop-map.schema.json": "4997f4b877495e6884c525e95b85bf0011aa1b498506a49510d8bb890594ca0d",
288
+ "spec/v2/interop-map.json": "a6bddce7f774f26ed178d2dd2e7268251acc9b1c9119e53dac116aa9c4fb2da0",
289
+ "spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
290
290
  "spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
291
291
  "spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
292
292
  "spec/v2/path-manifest.json": "ae9b56d03a701061fd48ad0b73cb0373493f6a4547da92763b422295b5e19535",
293
293
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
294
294
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
295
- "spec/v2/release.json": "39087f64480a257ee2996f153c8530e6250480bfe7d8b9d12c17156b85faa32a",
295
+ "spec/v2/release.json": "e6cdd54b2e2b03cc6f8ccb56c977ba94188b66d9cb0a130053429eec219f95b5",
296
296
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
297
- "spec/v2/surface-baseline.json": "efdb4e5e0eb16689e20dea2be6f326971bb5707990c31896ca4cf24c6a5bbfed"
297
+ "spec/v2/surface-baseline.json": "c07ff8163c6aadbd8673669ce07aa798ad4c8f99d59e8c33eaf5171f17777b10"
298
298
  },
299
- "corpusCommit": "a427fd35ec19058b6c7dcbf418e89d03798d20b8"
299
+ "corpusCommit": "9b84226d4b9d3e81426065472016a5ef5deee933"
300
300
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * RFC 0211 — reading an A2A 1.0 error's details.
3
+ *
4
+ * A2A v1.0.1 §9.5: a JSON-RPC error's `data` is an ARRAY of ProtoJSON `Any`
5
+ * objects, each carrying `@type`; §10.6/§11.6 name `google.rpc.ErrorInfo`
6
+ * (`reason` UPPER_SNAKE without the `Error` suffix, `domain`
7
+ * `a2a-protocol.org`, `metadata` a `map<string,string>`). The v2 profile makes
8
+ * the ErrorInfo entry a MUST on the JSON-RPC binding (`interop.md`
9
+ * §"The operation mappings", A2A error details).
10
+ */
11
+
12
+ export const ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo';
13
+ export const A2A_ERROR_DOMAIN = 'a2a-protocol.org';
14
+
15
+ export interface ErrorInfo { reason?: unknown; domain?: unknown; metadata?: unknown }
16
+
17
+ /** The ErrorInfo entries of an upstream-shaped `data` array (empty when `data` is not an array). */
18
+ export function errorInfos(data: unknown): ErrorInfo[] {
19
+ if (!Array.isArray(data)) return [];
20
+ return data.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null && (x as Record<string, unknown>)['@type'] === ERROR_INFO_TYPE) as ErrorInfo[];
21
+ }
22
+
23
+ /**
24
+ * The normalised details of an error, for comparing two answers (RFC 0211 §D):
25
+ * per element `@type`, `reason`, `domain` and the sorted metadata keys, with an
26
+ * echo of the requested id removed. Comparing `Object.keys(data)` instead is
27
+ * vacuous: a one-element array has keys `["0"]` whatever it discloses.
28
+ */
29
+ export function normaliseErrorData(data: unknown, requestedId?: string): Array<Record<string, unknown>> {
30
+ if (!Array.isArray(data)) return [{ notAnArray: typeof data, keys: data && typeof data === 'object' ? Object.keys(data as object).sort() : [] }];
31
+ return data.map((x) => {
32
+ const e = (typeof x === 'object' && x !== null ? x : {}) as Record<string, unknown>;
33
+ const md = (typeof e['metadata'] === 'object' && e['metadata'] !== null ? e['metadata'] : {}) as Record<string, unknown>;
34
+ const keys = Object.keys(md).filter((k) => !(requestedId !== undefined && md[k] === requestedId)).sort();
35
+ return { type: e['@type'], reason: e['reason'], domain: e['domain'], metadataKeys: keys };
36
+ });
37
+ }
38
+
39
+ /** True when a body is the OpenWOP error envelope `{ error: "<code>", message }` (schemas/v2/error-envelope.schema.json). */
40
+ export function isOpenwopEnvelope(body: unknown): boolean {
41
+ if (typeof body !== 'object' || body === null) return false;
42
+ const b = body as Record<string, unknown>;
43
+ return typeof b['error'] === 'string' && typeof b['message'] === 'string' && !('jsonrpc' in b);
44
+ }
@@ -38,8 +38,9 @@
38
38
  * @see https://a2a-protocol.org/v0.3.0/specification
39
39
  */
40
40
 
41
- import { resolvePublicFront } from './webhook-receiver.js';
42
- import { createServer, type Server } from 'node:http';
41
+ import { randomBytes } from 'node:crypto';
42
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
43
+ import { frontedEndpoint, registerBehindFront, routeFronted, unregisterBehindFront } from './front-mux.js';
43
44
  import type { AddressInfo } from 'node:net';
44
45
 
45
46
  export type A2ATaskState =
@@ -138,13 +139,30 @@ export interface A2APeerInvocation {
138
139
  readonly headers: Readonly<Record<string, string>>;
139
140
  }
140
141
 
142
+ /**
143
+ * A2A 1.0.1 §9.5: a JSON-RPC error's `data` is an ARRAY of ProtoJSON `Any` objects,
144
+ * each carrying `@type`; §10.6/§11.6 name `google.rpc.ErrorInfo` with the error type in
145
+ * UPPER_SNAKE (no "Error" suffix) as `reason` and `a2a-protocol.org` as `domain`.
146
+ * `ErrorInfo.metadata` is `map<string,string>`, so list values travel comma-joined.
147
+ * Through suite 2.36.1 this peer emitted a bare `{ reason, domain }` object, a shape no
148
+ * upstream SDK sends (a2a-js 1.2.0 and a2a-python 1.1.5 both send the array).
149
+ */
150
+ export function a2aErrorInfo(reason: string, metadata: Record<string, string> = {}): Array<Record<string, unknown>> {
151
+ const info: Record<string, unknown> = { '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason, domain: 'a2a-protocol.org' };
152
+ if (Object.keys(metadata).length > 0) info['metadata'] = metadata;
153
+ return [info];
154
+ }
155
+
141
156
  export class A2AFakePeer {
142
157
  private _server: Server | null = null;
143
158
  private _boundPort = 0;
159
+ /** This fake's path segment behind a shared public front (`lib/front-mux.ts`). */
160
+ private readonly _nonce = randomBytes(9).toString('hex');
144
161
  private readonly _tasks = new Map<string, A2ATask>();
145
162
  private readonly _invocations: A2APeerInvocation[] = [];
146
163
  private _nextStateOverride: A2ATaskState | null = null;
147
164
  private _nextPeerAssertsAuthority = false;
165
+ private _legacyErrorData = false;
148
166
  private _taskIdCounter = 0;
149
167
  private readonly _protocolVersions: readonly A2AProtocolVersion[];
150
168
 
@@ -168,19 +186,26 @@ export class A2AFakePeer {
168
186
 
169
187
  async start(port: number = 0): Promise<void> {
170
188
  return new Promise((resolve, reject) => {
171
- const server = createServer((req, res) => {
189
+ const dispatch = (req: IncomingMessage, res: ServerResponse): void => {
172
190
  this._handle(req, res).catch((err) => {
173
191
  if (!res.headersSent) {
174
192
  res.writeHead(500, { 'Content-Type': 'application/json' });
175
193
  res.end(JSON.stringify({ error: String(err) }));
176
194
  }
177
195
  });
196
+ };
197
+ // Whichever fake owns the pinned port also carries every other fake's
198
+ // nonce-pathed traffic (`lib/front-mux.ts`) — ask that first.
199
+ const server = createServer((req, res) => {
200
+ if (routeFronted('OPENWOP_A2A_FAKE_PEER_URL', req, res)) return;
201
+ dispatch(req, res);
178
202
  });
179
203
  server.on('error', reject);
180
204
  server.listen(port, '127.0.0.1', () => {
181
205
  const addr = server.address() as AddressInfo;
182
206
  this._server = server;
183
207
  this._boundPort = addr.port;
208
+ registerBehindFront('OPENWOP_A2A_FAKE_PEER_URL', this._nonce, dispatch);
184
209
  resolve();
185
210
  });
186
211
  });
@@ -190,6 +215,7 @@ export class A2AFakePeer {
190
215
  if (!this._server) return;
191
216
  const server = this._server;
192
217
  this._server = null;
218
+ unregisterBehindFront('OPENWOP_A2A_FAKE_PEER_URL', this._nonce);
193
219
  return new Promise((resolve, reject) => {
194
220
  server.close((err) => (err ? reject(err) : resolve()));
195
221
  });
@@ -208,7 +234,23 @@ export class A2AFakePeer {
208
234
  * fixed port to forward to.
209
235
  */
210
236
  hostFacingEndpoint(): string {
211
- return resolvePublicFront('OPENWOP_A2A_FAKE_PEER_URL', this.endpoint()).url;
237
+ return frontedEndpoint('OPENWOP_A2A_FAKE_PEER_URL', 'OPENWOP_A2A_FAKE_PEER_PORT', this.endpoint(), this._boundPort, this._nonce);
238
+ }
239
+
240
+ /**
241
+ * RFC 0211 §F: answer errors with the pre-2.37.0 bare `{ reason, domain, metadata }`
242
+ * object instead of the upstream `Any[]`, so a scenario can hold a host's A2A client
243
+ * to reading BOTH shapes through 2.x. Cleared by `reset()`.
244
+ */
245
+ setLegacyErrorData(on: boolean): void {
246
+ this._legacyErrorData = on;
247
+ }
248
+
249
+ private _errorData(reason: string, metadata: Record<string, string>): unknown {
250
+ const arr = a2aErrorInfo(reason, metadata);
251
+ if (!this._legacyErrorData) return arr;
252
+ const { ['@type']: _t, ...rest } = arr[0]!;
253
+ return rest;
212
254
  }
213
255
 
214
256
  reset(): void {
@@ -216,6 +258,7 @@ export class A2AFakePeer {
216
258
  this._invocations.length = 0;
217
259
  this._nextStateOverride = null;
218
260
  this._nextPeerAssertsAuthority = false;
261
+ this._legacyErrorData = false;
219
262
  this._taskIdCounter = 0;
220
263
  }
221
264
 
@@ -436,15 +479,15 @@ export class A2AFakePeer {
436
479
  error: {
437
480
  code: A2A10_ERR.VERSION_NOT_SUPPORTED,
438
481
  message: `A2A protocol version ${requested} is not supported by this agent`,
439
- data: { reason: 'VERSION_NOT_SUPPORTED', domain: 'a2a-protocol.org', requested, supportedVersions: [...this._protocolVersions] },
482
+ data: this._errorData('VERSION_NOT_SUPPORTED', { requested, supportedVersions: this._protocolVersions.join(',') }),
440
483
  },
441
484
  });
442
485
  return;
443
486
  }
444
487
  const version: A2AProtocolVersion = requested as A2AProtocolVersion;
445
488
  const ok = (result: unknown) => json(200, { jsonrpc: '2.0', id: rpcId, result });
446
- const err = (code: number, message: string, reason: string, extra: Record<string, unknown> = {}) =>
447
- json(200, { jsonrpc: '2.0', id: rpcId, error: { code, message, data: { reason, domain: 'a2a-protocol.org', ...extra } } });
489
+ const err = (code: number, message: string, reason: string, metadata: Record<string, string> = {}) =>
490
+ json(200, { jsonrpc: '2.0', id: rpcId, error: { code, message, data: this._errorData(reason, metadata) } });
448
491
 
449
492
  if (version === '1.0') {
450
493
  // ── A2A 1.0 JSON-RPC binding (spec §9.4) ──
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The suite's own destination for ONE staged outbound effect — the oracle RFC
3
+ * 0158 §C's exactly-once claim is counted at.
4
+ *
5
+ * ── Why this file exists (2.37.0) ────────────────────────────────────────────
6
+ * Two scenarios stage the SAME host seam (`POST /host/durability/kill`,
7
+ * `mode=duplicate-delivery`) and each counted arrivals at its own copy of a
8
+ * receiver: `v2-durability-recovery.test.ts` (`0158.duplicate-delivery`) and
9
+ * `v2-terminal-event-once.test.ts` (`0194.terminal-once.duplicate-delivery`).
10
+ * Both handed the seam the SAME destination — `resolveRegistrationUrl(...)`,
11
+ * which on a tunnelled cut returns `OPENWOP_WEBHOOK_RECEIVER_URL` verbatim, a
12
+ * byte-identical string for every caller.
13
+ *
14
+ * An outbound effect's Layer-2 identity is its BUSINESS identity — tenant,
15
+ * workflow, node, request digest, and no runId (`spec/v1/idempotency.md`
16
+ * §"Layer 2 Keying"; RFC 0150 §B). Two exercises that stage the same fixture at
17
+ * the same URL therefore have the SAME effect identity, and a conformant host
18
+ * MUST resolve the second one to the first one's recorded outcome instead of
19
+ * calling out again. Measured on the v2 reference host: exercise 1 landed one
20
+ * arrival; exercise 2, identical URL, landed ZERO and its ledger row read
21
+ * `invocationId: "deduplicated-of:<run 1>"`.
22
+ *
23
+ * So whichever of the two legs vitest happened to run SECOND observed zero
24
+ * arrivals — `blocked` on loopback, and a hard `executed-fail` on a tunnelled
25
+ * cut, where zero deliveries must never read as a pass. Nothing about the host
26
+ * changed between runs; only the file order did. That is a suite defect: the
27
+ * row measured cross-exercise deduplication (correct behaviour) instead of the
28
+ * within-exercise exactly-once RFC 0158 §C actually states.
29
+ *
30
+ * The fix is here rather than in either scenario, because two copies of a
31
+ * receiver are what let the two legs collide in the first place: every
32
+ * destination this helper mints carries a fresh NONCE in its path, so every
33
+ * exercise has its own effect identity by construction, and each receiver
34
+ * counts ONLY the arrivals bearing its own nonce. A stale retry aimed at a
35
+ * shared front can no longer be counted as this exercise's second fire, and a
36
+ * sibling leg can no longer consume this exercise's identity.
37
+ *
38
+ * @see spec/v1/idempotency.md §"Layer 2 Keying"
39
+ * @see RFCS/0158-durable-execution-and-disaster-recovery-qualification.md §C
40
+ */
41
+
42
+ import { randomBytes } from 'node:crypto';
43
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
44
+ import { receiverBinding, resolveRegistrationUrl } from './webhook-receiver.js';
45
+
46
+ export interface EffectArrival {
47
+ /** The request line's path, as the host addressed it. */
48
+ readonly path: string;
49
+ readonly method: string;
50
+ readonly at: number;
51
+ /** False when the path carried some other exercise's nonce (or none). */
52
+ readonly mine: boolean;
53
+ }
54
+
55
+ export interface EffectReceiver {
56
+ readonly server: Server;
57
+ /** The destination handed to the host: the public front when one is wired, else the local address. */
58
+ readonly url: string;
59
+ /** True when `url` is an operator-supplied public front rather than this listener's address. */
60
+ readonly tunnelled: boolean;
61
+ /** The local address this listener actually answers on — for failure detail. */
62
+ readonly localUrl: string;
63
+ /** The per-exercise nonce that makes this destination, and so this effect's identity, unique. */
64
+ readonly nonce: string;
65
+ /** Arrivals bearing this exercise's nonce. THE count RFC 0158 §C asks for. */
66
+ arrivals(): number;
67
+ /** Arrivals at this listener that belong to some other exercise — reported, never counted. */
68
+ foreign(): number;
69
+ all(): readonly EffectArrival[];
70
+ close(): Promise<void>;
71
+ }
72
+
73
+ /**
74
+ * Wait until at least one arrival is observed, or `budgetMs` elapses.
75
+ *
76
+ * A blind sleep measures the host's effect LATENCY, not its exactly-once
77
+ * behaviour: on a loaded machine the one legitimate delivery can trail the
78
+ * run's terminal status by more than a fixed window, and the row then reads
79
+ * zero and calls a conformant host non-conformant. Waiting FOR the arrival and
80
+ * only then waiting OUT the quiet window keeps both halves honest — the first
81
+ * bound may be generous without weakening anything, because the assertion that
82
+ * follows is that a SECOND arrival never comes.
83
+ */
84
+ export async function waitForFirstArrival(rx: EffectReceiver, budgetMs: number): Promise<number> {
85
+ const deadline = Date.now() + budgetMs;
86
+ while (rx.arrivals() === 0 && Date.now() < deadline) {
87
+ await new Promise((r) => setTimeout(r, 100));
88
+ }
89
+ return rx.arrivals();
90
+ }
91
+
92
+ /**
93
+ * Start a receiver for exactly one staged effect.
94
+ *
95
+ * Honours `OPENWOP_WEBHOOK_RECEIVER_PORT` so a tunnelled cut forwards here (a
96
+ * certification cut already runs `--max-workers 1`, so the pinned port is not
97
+ * contended). The nonce is in the PATH, never the port, so the destination is
98
+ * distinct per exercise whether the port is pinned, ephemeral, or replaced by
99
+ * an operator's public front.
100
+ */
101
+ export async function startEffectReceiver(): Promise<EffectReceiver> {
102
+ const nonce = randomBytes(9).toString('hex');
103
+ const path = `/effect/${nonce}`;
104
+ const seen: EffectArrival[] = [];
105
+ const server = createServer((request: IncomingMessage, res: ServerResponse) => {
106
+ request.on('data', () => { /* drain */ });
107
+ request.on('end', () => {
108
+ const requestPath = request.url ?? '';
109
+ // Matched by CONTAINMENT, not equality: a TLS-terminating front may add
110
+ // or strip a prefix, and the nonce is what identifies the exercise.
111
+ seen.push({ path: requestPath, method: request.method ?? '', at: Date.now(), mine: requestPath.includes(nonce) });
112
+ res.writeHead(204);
113
+ res.end();
114
+ });
115
+ });
116
+ const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
117
+ const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
118
+ const binding = receiverBinding();
119
+ await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
120
+ const addr = server.address();
121
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
122
+ const localUrl = `http://${binding.advertise}:${port}${path}`;
123
+ // The front is resolved on the ORIGIN and the nonce path appended, so the
124
+ // per-exercise path survives a tunnel — `resolveRegistrationUrl` returns the
125
+ // operator's value verbatim and would otherwise drop it.
126
+ const front = resolveRegistrationUrl(`http://${binding.advertise}:${port}`);
127
+ const url = `${front.url.replace(/\/+$/, '')}${path}`;
128
+ return {
129
+ server, url, localUrl, nonce, tunnelled: front.tunnelled,
130
+ arrivals: () => seen.filter((a) => a.mine).length,
131
+ foreign: () => seen.filter((a) => !a.mine).length,
132
+ all: () => seen,
133
+ close: () => new Promise<void>((r) => server.close(() => r())),
134
+ };
135
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * One public front, many suite fakes — path multiplexing behind
3
+ * `OPENWOP_A2A_FAKE_PEER_URL` / `OPENWOP_MCP_FAKE_SERVER_URL`.
4
+ *
5
+ * ── Why this file exists (2.37.0) ────────────────────────────────────────────
6
+ * An operator fronts ONE listener per fixture: the tunnel forwards the public
7
+ * https origin to the pinned `_PORT`, where `setup.ts` starts the shared fake.
8
+ * Several scenarios also construct a fake of their OWN — pinned to a protocol
9
+ * version the shared one does not speak (a 0.3-only peer for the floor, a
10
+ * 2025-06-18-only server for negotiation) or just fresh — start it on an
11
+ * ephemeral port, and hand the host `hostFacingEndpoint()`. Until 2.37.0 that
12
+ * returned the front URL verbatim, so on a tunnelled cut the host's request
13
+ * went through the tunnel to the SHARED fake while the scenario read its own,
14
+ * which had seen nothing. Two consequences, both measured on the v2 reference
15
+ * host:
16
+ *
17
+ * - `0207.a2a-traceparent-carried` recorded `blocked` in every public cut
18
+ * ("the suite peer received no SendMessage") — the host's A2A client had
19
+ * reached the suite, just not the listener that was counting;
20
+ * - the floor and authenticated-negotiation legs passed VACUOUSLY: the
21
+ * refusal is forced by the seam's `peerOffersOnly` knob, and the wire check
22
+ * "no below-floor call reached the peer" iterated a peer nobody called.
23
+ *
24
+ * Loopback cuts never showed it, because each fake's local address is distinct.
25
+ *
26
+ * The fix is the shape openwop#1513 used for the effect receiver: a per-fake
27
+ * NONCE in the path. A fake that does not own the pinned port is handed
28
+ * `${front}/fx/<nonce>`; whichever fake DOES own it (every fake's handler asks
29
+ * first) strips the prefix and dispatches to the registered instance. Every
30
+ * fake is then reachable through the one front, and each one counts only what
31
+ * was addressed to it.
32
+ */
33
+
34
+ import type { IncomingMessage, ServerResponse } from 'node:http';
35
+ import { resolvePublicFront } from './webhook-receiver.js';
36
+
37
+ type Handler = (req: IncomingMessage, res: ServerResponse) => void;
38
+
39
+ const PREFIX = '/fx/';
40
+ const registry = new Map<string, Map<string, Handler>>();
41
+
42
+ function pinnedPort(portEnv: string): number {
43
+ const n = Number(process.env[portEnv] ?? '');
44
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : 0;
45
+ }
46
+
47
+ /** Make `handler` reachable at `${front}/fx/<nonce>` through whichever fake owns the pinned port. */
48
+ export function registerBehindFront(frontEnv: string, nonce: string, handler: Handler): void {
49
+ let m = registry.get(frontEnv);
50
+ if (!m) registry.set(frontEnv, (m = new Map()));
51
+ m.set(nonce, handler);
52
+ }
53
+
54
+ export function unregisterBehindFront(frontEnv: string, nonce: string): void {
55
+ registry.get(frontEnv)?.delete(nonce);
56
+ }
57
+
58
+ /**
59
+ * Dispatch a request addressed to another fake's nonce. Returns true when it
60
+ * did (the caller then does nothing else). The prefix is stripped so the
61
+ * target sees exactly the path it would have seen on its own listener.
62
+ */
63
+ export function routeFronted(frontEnv: string, req: IncomingMessage, res: ServerResponse): boolean {
64
+ const url = req.url ?? '/';
65
+ if (!url.startsWith(PREFIX)) return false;
66
+ const rest = url.slice(PREFIX.length);
67
+ const slash = rest.indexOf('/');
68
+ const q = rest.indexOf('?');
69
+ const end = slash === -1 ? (q === -1 ? rest.length : q) : q === -1 ? slash : Math.min(slash, q);
70
+ const target = registry.get(frontEnv)?.get(rest.slice(0, end));
71
+ if (!target) {
72
+ // Addressed to a fake that has stopped (or never existed). Answer, never
73
+ // fall through: handling it here would count a stranger's request.
74
+ res.writeHead(404, { 'Content-Type': 'application/json' });
75
+ res.end(JSON.stringify({ error: 'no suite fake is registered under this nonce' }));
76
+ return true;
77
+ }
78
+ const tail = rest.slice(end);
79
+ req.url = tail.startsWith('/') ? tail : `/${tail}`;
80
+ target(req, res);
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * The address to hand the host under test. No front ⇒ the local address. A
86
+ * front and this fake owns the pinned port ⇒ the front itself (unchanged from
87
+ * before 2.37.0). A front and any other port ⇒ the front plus this fake's
88
+ * nonce path, which the pinned owner routes back here.
89
+ */
90
+ export function frontedEndpoint(frontEnv: string, portEnv: string, localUrl: string, boundPort: number, nonce: string): string {
91
+ const front = resolvePublicFront(frontEnv, localUrl);
92
+ if (!front.tunnelled) return localUrl;
93
+ const base = front.url.replace(/\/+$/, '');
94
+ return boundPort === pinnedPort(portEnv) ? base : `${base}${PREFIX}${nonce}`;
95
+ }
@@ -34,8 +34,9 @@
34
34
  * @see SECURITY/threat-model-prompt-injection.md §"UNTRUSTED marker"
35
35
  */
36
36
 
37
- import { resolvePublicFront } from './webhook-receiver.js';
38
- import { createServer, type Server } from 'node:http';
37
+ import { randomBytes } from 'node:crypto';
38
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
39
+ import { frontedEndpoint, registerBehindFront, routeFronted, unregisterBehindFront } from './front-mux.js';
39
40
  import type { AddressInfo } from 'node:net';
40
41
 
41
42
  export interface McpInvocation {
@@ -140,6 +141,8 @@ export const TOOLS_PAGE_SIZE = 3;
140
141
  export class McpFakeServer {
141
142
  private _server: Server | null = null;
142
143
  private _boundPort = 0;
144
+ /** This fake's path segment behind a shared public front (`lib/front-mux.ts`). */
145
+ private readonly _nonce = randomBytes(9).toString('hex');
143
146
  private readonly _invocations: McpInvocation[] = [];
144
147
  private readonly _revisions: readonly McpRevision[];
145
148
  private _stateCounter = 0;
@@ -170,12 +173,19 @@ export class McpFakeServer {
170
173
 
171
174
  async start(port: number = 0): Promise<void> {
172
175
  return new Promise((resolve, reject) => {
173
- const server = createServer((req, res) => this._handle(req, res));
176
+ const dispatch = (req: IncomingMessage, res: ServerResponse): void => { void this._handle(req, res); };
177
+ // Whichever fake owns the pinned port also carries every other fake's
178
+ // nonce-pathed traffic (`lib/front-mux.ts`) — ask that first.
179
+ const server = createServer((req, res) => {
180
+ if (routeFronted('OPENWOP_MCP_FAKE_SERVER_URL', req, res)) return;
181
+ dispatch(req, res);
182
+ });
174
183
  server.on('error', reject);
175
184
  server.listen(port, '127.0.0.1', () => {
176
185
  const addr = server.address() as AddressInfo;
177
186
  this._server = server;
178
187
  this._boundPort = addr.port;
188
+ registerBehindFront('OPENWOP_MCP_FAKE_SERVER_URL', this._nonce, dispatch);
179
189
  resolve();
180
190
  });
181
191
  });
@@ -185,6 +195,7 @@ export class McpFakeServer {
185
195
  if (!this._server) return;
186
196
  const server = this._server;
187
197
  this._server = null;
198
+ unregisterBehindFront('OPENWOP_MCP_FAKE_SERVER_URL', this._nonce);
188
199
  return new Promise((resolve, reject) => {
189
200
  server.close((err) => (err ? reject(err) : resolve()));
190
201
  });
@@ -203,7 +214,7 @@ export class McpFakeServer {
203
214
  * fixed port to forward to.
204
215
  */
205
216
  hostFacingEndpoint(): string {
206
- return resolvePublicFront('OPENWOP_MCP_FAKE_SERVER_URL', this.endpoint()).url;
217
+ return frontedEndpoint('OPENWOP_MCP_FAKE_SERVER_URL', 'OPENWOP_MCP_FAKE_SERVER_PORT', this.endpoint(), this._boundPort, this._nonce);
207
218
  }
208
219
 
209
220
  invocations(): readonly McpInvocation[] {