@openwop/openwop-conformance 2.36.1 → 2.37.1
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 +22 -0
- package/README.md +4 -3
- package/coverage.md +1 -0
- package/dist/lib/requirement-ledger.js +44 -3
- package/dist/lib/scenario-disposition.js +37 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +510 -73
- package/scenario-majors.json +14 -2
- package/schemas/CORPUS-STAMP.json +27 -27
- package/src/lib/a2a-error-info.ts +44 -0
- package/src/lib/a2a-fake-peer.ts +50 -7
- package/src/lib/effect-receiver.ts +135 -0
- package/src/lib/front-mux.ts +102 -0
- package/src/lib/mcp-fake-server.ts +15 -4
- package/src/lib/oidc-issuer.ts +15 -5
- package/src/lib/requirement-ledger.ts +82 -4
- package/src/lib/scenario-disposition.ts +41 -3
- package/src/lib/scoped-receiver.ts +223 -0
- package/src/lib/triggerBridge.ts +49 -0
- package/src/scenarios/a2a-1-0-agent-card.test.ts +19 -7
- package/src/scenarios/auth-subject-link.test.ts +18 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
- package/src/scenarios/v2-a2a-client-error-details.test.ts +70 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +84 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
- package/src/scenarios/v2-durability-recovery.test.ts +83 -35
- package/src/scenarios/v2-idempotency-in-flight.test.ts +132 -0
- package/src/scenarios/v2-interrupt-resolve-terminal.test.ts +107 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +41 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +13 -0
- package/src/scenarios/v2-sse-last-event-id-cursor.test.ts +133 -0
- package/src/scenarios/v2-terminal-event-once.test.ts +15 -21
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
- package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
- package/src/setup.ts +24 -4
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": 557,
|
|
5
5
|
"v1": 448,
|
|
6
|
-
"v2":
|
|
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.
|
|
5
|
-
"corpusTag": "v2.
|
|
4
|
+
"version": "2.37.1",
|
|
5
|
+
"corpusTag": "v2.37.1",
|
|
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": "
|
|
10
|
+
"api/openapi.yaml": "6366b5514be71098e9ea41fc81ff52bc39db1a547b353c0b344cc2a2bf5370aa",
|
|
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": "02711723089859b95144d788c0caabf215305570c7d48be1da5d2a5e21eff4e1",
|
|
13
|
+
"api/v2/asyncapi.yaml": "d492342046d1ec3a2dc43a28c4cf6d821d6319c30a8552cbc8fcd13806f7b2df",
|
|
14
|
+
"api/v2/openapi.yaml": "ba67b61f0e716444308ef7bc00416b8eee5829c28b67c5070358d35c3ca64d36",
|
|
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": "
|
|
120
|
-
"schemas/v2/certification-bundle.schema.json": "
|
|
119
|
+
"schemas/v2/capabilities.schema.json": "79ddbbda4686fe66ec5b0aaf9bafb2385e2f8ccbb1ee39087d75cef9fd1f1628",
|
|
120
|
+
"schemas/v2/certification-bundle.schema.json": "cb8ddf29a008b1c2e731fd61f855bf072790b700e458c702c40434b6fd47ba73",
|
|
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": "
|
|
207
|
+
"spec/v1/core-standard-manifest.json": "c92119aa65b12b7f670cc59e7768177d20f8f47173e7fbd15c3b0a6ef340810a",
|
|
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": "
|
|
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": "
|
|
218
|
-
"spec/v1/spec-gaps.json": "
|
|
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": "
|
|
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": "
|
|
225
|
-
"spec/v2/core/events.md": "
|
|
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": "
|
|
227
|
+
"spec/v2/core/headers.md": "38c03524e3aeea30862432d203faffb8b0b05ec65136d223e96ab06941c3717b",
|
|
228
228
|
"spec/v2/core/host-services.md": "c3ff93201d642edf835e9ae84f06a17edd207f9d351f8bfd70572be05bbfd894",
|
|
229
|
-
"spec/v2/core/idempotency.md": "
|
|
229
|
+
"spec/v2/core/idempotency.md": "b61d4a200526485937897dd7ebca092e68424a80624e0be7a0b7db8cdbd54403",
|
|
230
230
|
"spec/v2/core/identity.md": "d15667552019f64316be9b65c6e5696c10766581405f05da3907fb47f36e6459",
|
|
231
|
-
"spec/v2/core/interop.md": "
|
|
232
|
-
"spec/v2/core/interrupt.md": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
289
|
-
"spec/v2/interop-map.schema.json": "
|
|
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": "
|
|
295
|
+
"spec/v2/release.json": "9422440ed08e9d8ff3e8190c3e91e862025fa8b5b49d468a536806f5a841ac13",
|
|
296
296
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
297
|
-
"spec/v2/surface-baseline.json": "
|
|
297
|
+
"spec/v2/surface-baseline.json": "13cfe5c31c3727a2e36980aa3e5a42f8ce1474b35beb99c5b71f487db006e71a"
|
|
298
298
|
},
|
|
299
|
-
"corpusCommit": "
|
|
299
|
+
"corpusCommit": "0d370019eaaa841491be58d196e6d7a6934c71f0"
|
|
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
|
+
}
|
package/src/lib/a2a-fake-peer.ts
CHANGED
|
@@ -38,8 +38,9 @@
|
|
|
38
38
|
* @see https://a2a-protocol.org/v0.3.0/specification
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import {
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
447
|
-
json(200, { jsonrpc: '2.0', id: rpcId, error: { code, message, data:
|
|
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,102 @@
|
|
|
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
|
+
/**
|
|
40
|
+
* The path prefix a fronted nonce lives under. Exported so a caller that mints
|
|
41
|
+
* its OWN destination (`scoped-receiver.ts`, which needs the nonce
|
|
42
|
+
* unconditionally rather than only when it loses the pinned port) spells the
|
|
43
|
+
* path the same way `routeFronted` parses it.
|
|
44
|
+
*/
|
|
45
|
+
export const FRONT_MUX_PREFIX = '/fx/';
|
|
46
|
+
const PREFIX = FRONT_MUX_PREFIX;
|
|
47
|
+
const registry = new Map<string, Map<string, Handler>>();
|
|
48
|
+
|
|
49
|
+
function pinnedPort(portEnv: string): number {
|
|
50
|
+
const n = Number(process.env[portEnv] ?? '');
|
|
51
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Make `handler` reachable at `${front}/fx/<nonce>` through whichever fake owns the pinned port. */
|
|
55
|
+
export function registerBehindFront(frontEnv: string, nonce: string, handler: Handler): void {
|
|
56
|
+
let m = registry.get(frontEnv);
|
|
57
|
+
if (!m) registry.set(frontEnv, (m = new Map()));
|
|
58
|
+
m.set(nonce, handler);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function unregisterBehindFront(frontEnv: string, nonce: string): void {
|
|
62
|
+
registry.get(frontEnv)?.delete(nonce);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Dispatch a request addressed to another fake's nonce. Returns true when it
|
|
67
|
+
* did (the caller then does nothing else). The prefix is stripped so the
|
|
68
|
+
* target sees exactly the path it would have seen on its own listener.
|
|
69
|
+
*/
|
|
70
|
+
export function routeFronted(frontEnv: string, req: IncomingMessage, res: ServerResponse): boolean {
|
|
71
|
+
const url = req.url ?? '/';
|
|
72
|
+
if (!url.startsWith(PREFIX)) return false;
|
|
73
|
+
const rest = url.slice(PREFIX.length);
|
|
74
|
+
const slash = rest.indexOf('/');
|
|
75
|
+
const q = rest.indexOf('?');
|
|
76
|
+
const end = slash === -1 ? (q === -1 ? rest.length : q) : q === -1 ? slash : Math.min(slash, q);
|
|
77
|
+
const target = registry.get(frontEnv)?.get(rest.slice(0, end));
|
|
78
|
+
if (!target) {
|
|
79
|
+
// Addressed to a fake that has stopped (or never existed). Answer, never
|
|
80
|
+
// fall through: handling it here would count a stranger's request.
|
|
81
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
82
|
+
res.end(JSON.stringify({ error: 'no suite fake is registered under this nonce' }));
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const tail = rest.slice(end);
|
|
86
|
+
req.url = tail.startsWith('/') ? tail : `/${tail}`;
|
|
87
|
+
target(req, res);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The address to hand the host under test. No front ⇒ the local address. A
|
|
93
|
+
* front and this fake owns the pinned port ⇒ the front itself (unchanged from
|
|
94
|
+
* before 2.37.0). A front and any other port ⇒ the front plus this fake's
|
|
95
|
+
* nonce path, which the pinned owner routes back here.
|
|
96
|
+
*/
|
|
97
|
+
export function frontedEndpoint(frontEnv: string, portEnv: string, localUrl: string, boundPort: number, nonce: string): string {
|
|
98
|
+
const front = resolvePublicFront(frontEnv, localUrl);
|
|
99
|
+
if (!front.tunnelled) return localUrl;
|
|
100
|
+
const base = front.url.replace(/\/+$/, '');
|
|
101
|
+
return boundPort === pinnedPort(portEnv) ? base : `${base}${PREFIX}${nonce}`;
|
|
102
|
+
}
|