@openwop/openwop-conformance 2.0.12 → 2.1.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 +95 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +80 -9
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +34 -32
- package/src/lib/webhook-receiver.ts +44 -0
- package/src/scenarios/replay-fanout-suppression.test.ts +4 -3
- package/src/scenarios/v2-run-list.test.ts +107 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +4 -3
- package/src/scenarios/webhook-signed-delivery.test.ts +48 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,100 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.1] — 2026-09-11 — `v2-run-list` is now actually selected
|
|
4
|
+
|
|
5
|
+
`scenario-majors.json` regenerated (517 files, v2 74): 2.1.0 packaged the
|
|
6
|
+
scenario but its registry row was missing, so `--target-major 2` never
|
|
7
|
+
selected it and every 2.1.0 run reported 73 files. The corpus gate now runs
|
|
8
|
+
`generate-scenario-majors.mjs --check`. No scenario prose changed.
|
|
9
|
+
|
|
10
|
+
## [2.1.0] — 2026-09-11 — the run-list scenario (RFC 0182)
|
|
11
|
+
|
|
12
|
+
New scenario `v2-run-list` (target major 2, gated on the `runList` family —
|
|
13
|
+
`inapplicable` on a host that does not advertise it), three legs:
|
|
14
|
+
|
|
15
|
+
- `openwop.requirement.0182.run-list.tenant-scoped` (one requirement, five assertions) — two runs the caller just created appear in its unfiltered list, every page validates against `run-list-response.schema.json` (so a bare `runId` fails the shape leg), no page exceeds the advertised `maxPageSize`, and every `runId` carries the caller's tenant segment — the segment of the ids the host minted for this credential moments earlier.
|
|
16
|
+
- `…cursor-refused` — a cursor the host did not mint is `400 validation_error`.
|
|
17
|
+
- `…filter-exact` — when `runList.filters` names `workflowId`, the filtered list contains only that workflow and still contains the run just created.
|
|
18
|
+
|
|
19
|
+
The 2.0.13 receiver fix (below) ships in this release; 2.0.13 itself was never
|
|
20
|
+
tagged. The pinned `@openwop/spec-artifacts` peer moves to `2.1.0`.
|
|
21
|
+
|
|
22
|
+
## [2.0.13] — 2026-09-10 — three webhook scenarios could not be witnessed off-process
|
|
23
|
+
|
|
24
|
+
`webhook-signed-delivery`, `replay-fanout-suppression` and
|
|
25
|
+
`v2-webhook-durable-delivery` each stand up their own HTTP receiver and hand the
|
|
26
|
+
host under test a URL to POST to. All three hard-coded `127.0.0.1` in the two
|
|
27
|
+
places that matter, so a host running anywhere but *this process* was told to
|
|
28
|
+
deliver to its own loopback.
|
|
29
|
+
|
|
30
|
+
MEASURED 2026-09-09 on a container lane whose harness doubles (compat provider,
|
|
31
|
+
OIDC issuer) were reachable via `--add-host host.docker.internal:host-gateway`,
|
|
32
|
+
from inside the container:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
"msg":"webhook delivery failed","url":"http://127.0.0.1:55469/",
|
|
36
|
+
"detail":"fetch failed (connect ECONNREFUSED 127.0.0.1:55469)"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Two independent blockers, both this suite's, neither the operator's: the
|
|
40
|
+
receiver **advertised** `127.0.0.1`, and it **bound loopback-only**, so no
|
|
41
|
+
external name could have reached it either. The lane was configured correctly.
|
|
42
|
+
The host signs webhooks correctly. The scenarios recorded a plain `fail` while
|
|
43
|
+
being unwitnessable — which is a false accusation, and the mirror of a gate that
|
|
44
|
+
cannot fail: a scenario whose pass condition cannot be satisfied in the posture
|
|
45
|
+
it is run in.
|
|
46
|
+
|
|
47
|
+
**Fix.** New `receiverBinding()` in `lib/webhook-receiver.ts`, used by all three.
|
|
48
|
+
It is a no-op by default: with `OPENWOP_CONFORMANCE_HARNESS_HOST` unset it
|
|
49
|
+
returns `{ bind: '127.0.0.1', advertise: '127.0.0.1' }`, byte-for-byte today's
|
|
50
|
+
behaviour, and **no receiver's bind widens for an in-process run**. Only when an
|
|
51
|
+
operator sets that variable — declaring that the host under test is somewhere
|
|
52
|
+
`127.0.0.1` does not name this process, and naming how it reaches this machine —
|
|
53
|
+
does the receiver bind `0.0.0.0` and advertise that name. The variable already
|
|
54
|
+
carried exactly this meaning for host-side harness doubles; the suite's own
|
|
55
|
+
receivers simply never consulted it.
|
|
56
|
+
|
|
57
|
+
**No SSRF gate is relaxed.** `host.docker.internal` is still a private address
|
|
58
|
+
over plain `http`, so the three gates in `webhook-signed-delivery`'s docblock
|
|
59
|
+
apply unchanged and a host must still opt in (or front the receiver with
|
|
60
|
+
`OPENWOP_WEBHOOK_RECEIVER_URL`, which waives nothing) to witness the row. All
|
|
61
|
+
this changes is that the packet now has somewhere to go.
|
|
62
|
+
|
|
63
|
+
### Second defect, same program: a fixed grace instead of a wait
|
|
64
|
+
|
|
65
|
+
With the address correct, `webhook-signed-delivery` still failed — now in 549ms,
|
|
66
|
+
with `expected 0 to be greater than 0`. It slept a fixed `setTimeout(500)`
|
|
67
|
+
"for fire-and-forget delivery to land". That assumes delivery is synchronous-ish
|
|
68
|
+
with run completion, which is true of a host that POSTs inline and false of one
|
|
69
|
+
doing what `webhooks.md` §"Delivery" asks for: a durable queue with retries,
|
|
70
|
+
drained by a worker on its own cadence. The reference host polls every 1000ms,
|
|
71
|
+
so the sleep expired before the worker's first tick. **The more durable a host's
|
|
72
|
+
delivery machinery, the more exposed it was here** — and this scenario is
|
|
73
|
+
typically the only automated oracle a host has on delivery header NAMES, so the
|
|
74
|
+
cost of the race was not a checkmark.
|
|
75
|
+
|
|
76
|
+
Replaced with a bounded wait: poll every 250ms until a delivery for this run
|
|
77
|
+
arrives or a 12s deadline passes. It still FAILS and never soft-skips; the
|
|
78
|
+
failure now means "did not deliver within 12s" rather than "did not deliver
|
|
79
|
+
within one arbitrary tick of an unrelated clock". The deadline is sized to sit
|
|
80
|
+
inside the 30s per-test budget alongside the existing 10s terminal poll, so a
|
|
81
|
+
genuinely slow host fails on the requirement message rather than on a bare
|
|
82
|
+
vitest timeout.
|
|
83
|
+
|
|
84
|
+
MEASURED end-to-end on the container lane, same image, three runs: **2 failed →
|
|
85
|
+
(address fixed) 1 failed, `replay-fanout-suppression` green → (wait fixed) 0
|
|
86
|
+
failed, both green.**
|
|
87
|
+
|
|
88
|
+
**Self-tests pin the call sites, not just the helper** — a helper that is
|
|
89
|
+
correct and unused is precisely the defect being fixed, and a unit test on
|
|
90
|
+
`receiverBinding()` alone stays green through a full revert of all three
|
|
91
|
+
scenarios. Sabotage-verified with disjoint red sets: reverting either the bind
|
|
92
|
+
or the advertise of any one scenario reds exactly that scenario's case, and each
|
|
93
|
+
helper branch reds only its own. The bounded wait is pinned the same way and for
|
|
94
|
+
a sharper reason — two consecutive container runs disagreed about the 500ms
|
|
95
|
+
sleep (0 deliveries at 549ms, then 1 at 318ms), so a behavioural test for it
|
|
96
|
+
would itself be a coin flip and could not be trusted to go red on a revert.
|
|
97
|
+
|
|
3
98
|
## [2.0.12] — 2026-09-10 — corpus peer moves with RFC 0181; no scenario change
|
|
4
99
|
|
|
5
100
|
No scenario, assertion or CLI change. The pinned `@openwop/spec-artifacts` peer
|
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
117
117
|
|
|
118
118
|
## What's Covered
|
|
119
119
|
|
|
120
|
-
The current suite has
|
|
120
|
+
The current suite has 517 scenario files under `src/scenarios/`.
|
|
121
121
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
122
122
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
123
123
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
@@ -462,7 +462,7 @@ Server-required (added in 1.7.0):
|
|
|
462
462
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
463
463
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
464
464
|
|
|
465
|
-
Current source tree:
|
|
465
|
+
Current source tree: 517 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
466
466
|
|
|
467
467
|
## Remaining Gaps
|
|
468
468
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.
|
|
59
|
+
"@openwop/spec-artifacts": "2.1.1"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 566,
|
|
6
|
+
"tests": 2156,
|
|
7
|
+
"withStableId": 2156,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2111
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -21615,7 +21615,7 @@
|
|
|
21615
21615
|
{
|
|
21616
21616
|
"id": "openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br",
|
|
21617
21617
|
"file": "replay-fanout-suppression.test.ts",
|
|
21618
|
-
"line":
|
|
21618
|
+
"line": 159,
|
|
21619
21619
|
"title": "delivers for a live run, suppresses for a replay fork, and delivers again for a branch fork",
|
|
21620
21620
|
"explicitId": "openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br",
|
|
21621
21621
|
"citations": [
|
|
@@ -28335,6 +28335,77 @@
|
|
|
28335
28335
|
}
|
|
28336
28336
|
]
|
|
28337
28337
|
},
|
|
28338
|
+
{
|
|
28339
|
+
"id": "openwop.it.v2-run-list.two-runs-the-caller-just-created-appear-every-id-is-bound-to-the-caller-s-tenant",
|
|
28340
|
+
"file": "v2-run-list.test.ts",
|
|
28341
|
+
"line": 62,
|
|
28342
|
+
"title": "two runs the caller just created appear, every id is bound to the caller's tenant, and no page exceeds maxPageSize",
|
|
28343
|
+
"explicitId": "openwop.requirement.0182.run-list.tenant-scoped",
|
|
28344
|
+
"citations": [
|
|
28345
|
+
{
|
|
28346
|
+
"section": "spec/v2/core/capabilities.md § runList",
|
|
28347
|
+
"requirement": null,
|
|
28348
|
+
"interpolated": true
|
|
28349
|
+
},
|
|
28350
|
+
{
|
|
28351
|
+
"section": "spec/v2/core/runs.md §List",
|
|
28352
|
+
"requirement": null,
|
|
28353
|
+
"interpolated": true
|
|
28354
|
+
},
|
|
28355
|
+
{
|
|
28356
|
+
"section": "spec/v2/core/runs.md §List",
|
|
28357
|
+
"requirement": null,
|
|
28358
|
+
"interpolated": true
|
|
28359
|
+
},
|
|
28360
|
+
{
|
|
28361
|
+
"section": "spec/v2/core/runs.md §List",
|
|
28362
|
+
"requirement": null,
|
|
28363
|
+
"interpolated": true
|
|
28364
|
+
},
|
|
28365
|
+
{
|
|
28366
|
+
"section": "spec/v2/core/identity.md §5",
|
|
28367
|
+
"requirement": null,
|
|
28368
|
+
"interpolated": true
|
|
28369
|
+
}
|
|
28370
|
+
]
|
|
28371
|
+
},
|
|
28372
|
+
{
|
|
28373
|
+
"id": "openwop.it.v2-run-list.a-cursor-the-host-did-not-mint-is-refused-400-validation-error",
|
|
28374
|
+
"file": "v2-run-list.test.ts",
|
|
28375
|
+
"line": 84,
|
|
28376
|
+
"title": "a cursor the host did not mint is refused 400 validation_error",
|
|
28377
|
+
"explicitId": "openwop.requirement.0182.run-list.cursor-refused",
|
|
28378
|
+
"citations": [
|
|
28379
|
+
{
|
|
28380
|
+
"section": "spec/v2/core/runs.md §List",
|
|
28381
|
+
"requirement": null,
|
|
28382
|
+
"interpolated": true
|
|
28383
|
+
},
|
|
28384
|
+
{
|
|
28385
|
+
"section": "spec/v2/core/errors.md",
|
|
28386
|
+
"requirement": "the refusal MUST carry validation_error"
|
|
28387
|
+
}
|
|
28388
|
+
]
|
|
28389
|
+
},
|
|
28390
|
+
{
|
|
28391
|
+
"id": "openwop.it.v2-run-list.when-the-filters-facet-names-workflowid-a-filtered-list-contains-only-that-workf",
|
|
28392
|
+
"file": "v2-run-list.test.ts",
|
|
28393
|
+
"line": 93,
|
|
28394
|
+
"title": "when the filters facet names workflowId, a filtered list contains only that workflow",
|
|
28395
|
+
"explicitId": "openwop.requirement.0182.run-list.filter-exact",
|
|
28396
|
+
"citations": [
|
|
28397
|
+
{
|
|
28398
|
+
"section": "spec/v2/core/runs.md §List",
|
|
28399
|
+
"requirement": null,
|
|
28400
|
+
"interpolated": true
|
|
28401
|
+
},
|
|
28402
|
+
{
|
|
28403
|
+
"section": "spec/v2/core/runs.md §List",
|
|
28404
|
+
"requirement": null,
|
|
28405
|
+
"interpolated": true
|
|
28406
|
+
}
|
|
28407
|
+
]
|
|
28408
|
+
},
|
|
28338
28409
|
{
|
|
28339
28410
|
"id": "openwop.it.v2-run-options-limits.more-than-100-tags-a-tag-over-256-characters-and-an-unknown-root-key-are-each-re",
|
|
28340
28411
|
"file": "v2-run-options-limits.test.ts",
|
|
@@ -28910,7 +28981,7 @@
|
|
|
28910
28981
|
{
|
|
28911
28982
|
"id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
|
|
28912
28983
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
28913
|
-
"line":
|
|
28984
|
+
"line": 233,
|
|
28914
28985
|
"title": "a failed attempt is retried and the event is delivered at least once",
|
|
28915
28986
|
"explicitId": "openwop.requirement.0173.webhook-durable-delivery",
|
|
28916
28987
|
"citations": [
|
|
@@ -28941,7 +29012,7 @@
|
|
|
28941
29012
|
{
|
|
28942
29013
|
"id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
|
|
28943
29014
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
28944
|
-
"line":
|
|
29015
|
+
"line": 314,
|
|
28945
29016
|
"title": "an exhausted delivery is dead-lettered, never dropped",
|
|
28946
29017
|
"explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
|
|
28947
29018
|
"citations": [
|
|
@@ -29746,7 +29817,7 @@
|
|
|
29746
29817
|
{
|
|
29747
29818
|
"id": "openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature",
|
|
29748
29819
|
"file": "webhook-signed-delivery.test.ts",
|
|
29749
|
-
"line":
|
|
29820
|
+
"line": 169,
|
|
29750
29821
|
"title": "host POSTs run events to subscriber with valid X-openwop-Signature",
|
|
29751
29822
|
"explicitId": "openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature",
|
|
29752
29823
|
"citations": [
|
|
@@ -29797,7 +29868,7 @@
|
|
|
29797
29868
|
{
|
|
29798
29869
|
"id": "openwop.it.webhook-signed-delivery.control-with-a-tunnel-wired-the-host-still-refuses-the-loopback-receiver",
|
|
29799
29870
|
"file": "webhook-signed-delivery.test.ts",
|
|
29800
|
-
"line":
|
|
29871
|
+
"line": 410,
|
|
29801
29872
|
"title": "control: with a tunnel wired, the host still refuses the loopback receiver",
|
|
29802
29873
|
"explicitId": "openwop.it.webhook-signed-delivery.control-with-a-tunnel-wired-the-host-still-refuses-the-loopback-receiver",
|
|
29803
29874
|
"citations": [
|
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": 517,
|
|
5
5
|
"v1": 445,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 74
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1406,6 +1406,9 @@
|
|
|
1406
1406
|
"v2-run-fork-refusals.test.ts": [
|
|
1407
1407
|
2
|
|
1408
1408
|
],
|
|
1409
|
+
"v2-run-list.test.ts": [
|
|
1410
|
+
2
|
|
1411
|
+
],
|
|
1409
1412
|
"v2-run-options-limits.test.ts": [
|
|
1410
1413
|
2
|
|
1411
1414
|
],
|
|
@@ -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.1.1",
|
|
5
|
+
"corpusTag": "v2.1.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
10
|
"api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
|
|
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": "8582c6105ed590b90bb3e9b8b0dca8a6b830dc7d027e5ec8bad000d4a90ee831",
|
|
13
|
+
"api/v2/asyncapi.yaml": "43a8cc8d71396a56a2d81b629f970c86fd3b8d93606cdd0e7463a860edb5625d",
|
|
14
|
+
"api/v2/openapi.yaml": "b5fe97ce54c18c99e3c13548fc10b554c93ee892d275ce1ee4e2f1caad571c79",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
"schemas/v2/artifact-type-pack-manifest.schema.json": "85630d1b760d0b41cf5a6fad2acfada39abaf7f0befd95cc4334e1e883e9615d",
|
|
116
116
|
"schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
|
|
117
117
|
"schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
|
|
118
|
-
"schemas/v2/capabilities.schema.json": "
|
|
118
|
+
"schemas/v2/capabilities.schema.json": "7b9c3853c4792f8242babfd70d6336a2ec365d4702944701e833ee5d45445ff5",
|
|
119
119
|
"schemas/v2/certification-bundle.schema.json": "d2cbc5cadddff13630a528dc9673162f582c035c6a6663ca02c1ac53e8fec40c",
|
|
120
120
|
"schemas/v2/channel-presence-payload.schema.json": "9475d46f358d21c19126e78204987a5633979917a2293653354ef5c5e044d54e",
|
|
121
121
|
"schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
|
|
@@ -171,6 +171,7 @@
|
|
|
171
171
|
"schemas/v2/run-diff-response.schema.json": "8bdb2045c274580038bd74781f7d062f5049839797c164a89de4ed89edc53a74",
|
|
172
172
|
"schemas/v2/run-event-payloads.schema.json": "1d3ccffc4e506d3109c17990e01976ba935e61672562684b9635743d56af3f57",
|
|
173
173
|
"schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
|
|
174
|
+
"schemas/v2/run-list-response.schema.json": "c2dbc34768dcf64528914a213aa3cb55952e8e3e90a64b04a9423b2a19749d38",
|
|
174
175
|
"schemas/v2/run-options.schema.json": "f0e9f494d80086c2ed2cf9fe87df54e5b4e66601699c4a1089f4edbc7ba58867",
|
|
175
176
|
"schemas/v2/run-orchestrator-decided-event.schema.json": "bb76e15ff72e669f12938684033b55dc567470e1a378e19ffc3fa0e4034ab5a7",
|
|
176
177
|
"schemas/v2/run-snapshot.schema.json": "65a3c82220da2e1a54324665d517dc0e627225f3d37ef75b43bef3fb7e3bac0e",
|
|
@@ -199,7 +200,7 @@
|
|
|
199
200
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
200
201
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
201
202
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
202
|
-
"spec/v1/core-standard-manifest.json": "
|
|
203
|
+
"spec/v1/core-standard-manifest.json": "c1f2002d3a2a77005d8a2ab69cb31c22f6c9dd235da2b4afcb3a0e90612699ba",
|
|
203
204
|
"spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
|
|
204
205
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
205
206
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -211,28 +212,28 @@
|
|
|
211
212
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
212
213
|
"spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
|
|
213
214
|
"spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
|
|
214
|
-
"spec/v2/README.md": "
|
|
215
|
-
"spec/v2/core/capabilities.md": "
|
|
216
|
-
"spec/v2/core/conformance.md": "
|
|
217
|
-
"spec/v2/core/connection-packs.md": "
|
|
218
|
-
"spec/v2/core/errors.md": "
|
|
219
|
-
"spec/v2/core/events.md": "
|
|
220
|
-
"spec/v2/core/form-content-packs.md": "
|
|
221
|
-
"spec/v2/core/headers.md": "
|
|
222
|
-
"spec/v2/core/idempotency.md": "
|
|
223
|
-
"spec/v2/core/identity.md": "
|
|
224
|
-
"spec/v2/core/interop.md": "
|
|
225
|
-
"spec/v2/core/interrupt.md": "
|
|
226
|
-
"spec/v2/core/overview.md": "
|
|
227
|
-
"spec/v2/core/packs.md": "
|
|
228
|
-
"spec/v2/core/persistence.md": "
|
|
229
|
-
"spec/v2/core/replay.md": "
|
|
230
|
-
"spec/v2/core/runs.md": "
|
|
231
|
-
"spec/v2/core/security-defaults.md": "
|
|
232
|
-
"spec/v2/core/versioning.md": "
|
|
233
|
-
"spec/v2/core/webhooks.md": "
|
|
234
|
-
"spec/v2/core/workflow-chain-packs.md": "
|
|
235
|
-
"spec/v2/declaration.json": "
|
|
215
|
+
"spec/v2/README.md": "ade62f23b12c18416669bd46dfa417d46831193184f34f90668e22a98b9ee3f4",
|
|
216
|
+
"spec/v2/core/capabilities.md": "c5107a4964e487c20bbb5254cfe1f706e0d9ba7e34da9070bc43b50b385e7948",
|
|
217
|
+
"spec/v2/core/conformance.md": "622084e3c74c4549087f4d861b1bb98cec201a0c5aec23788d27f53a355fde3b",
|
|
218
|
+
"spec/v2/core/connection-packs.md": "1d759681e2962f5103228c52c45f24607ed4bb9ae73e30605f690c1d5a59e9ee",
|
|
219
|
+
"spec/v2/core/errors.md": "74fdb5862f432a831cf0c2eadbe0c8cca6b2660ec578180ae33db54773a1df28",
|
|
220
|
+
"spec/v2/core/events.md": "defe0969d6ec7c980f415cc0d78b9d065782ac7e5638b12a44aa4895a35d6126",
|
|
221
|
+
"spec/v2/core/form-content-packs.md": "6ffaae85d4550be48dd53fb6e2959af8aceb35bd87c5343b722885084f197713",
|
|
222
|
+
"spec/v2/core/headers.md": "412556e1536f7eb05a2bfa8cbcabe3a4bc3e5410ccd46cd1a7295395f175b735",
|
|
223
|
+
"spec/v2/core/idempotency.md": "b86c352d964aebad532727f314e26ed2177d49de382076fa64d3162e2f56118d",
|
|
224
|
+
"spec/v2/core/identity.md": "dd5299f9cdf54640870e1fffae11edb820cf6396ea298c4ebd14c649c4bd4a87",
|
|
225
|
+
"spec/v2/core/interop.md": "d4c404e469e1d9d9be88bc2c939d777e7d7c1d0419b1251c18770f039838a401",
|
|
226
|
+
"spec/v2/core/interrupt.md": "d872e0652a938594eeb2a19e86cf64e64e29b234f5d9c4fbe2970ab5fb191b9c",
|
|
227
|
+
"spec/v2/core/overview.md": "69377c90bbb87b6bfe4b8be4f1b65810094b0759c8d4f93ca6df92cc530ea023",
|
|
228
|
+
"spec/v2/core/packs.md": "e72a7586a61630cdfd342eaf84e989f864b3e199006f5511ef59ea7c1a1c56f7",
|
|
229
|
+
"spec/v2/core/persistence.md": "2373c2815b25b7b4fe29dfdf3783d3f2f3e637d3abce5085e69f019546302fb6",
|
|
230
|
+
"spec/v2/core/replay.md": "bd191a2565aa7ad0df9b35a0716f3de53be34b8722f1807c5e5b02859404a9b9",
|
|
231
|
+
"spec/v2/core/runs.md": "f7a95fc82fda5b7c0c613de6e5edd376bbd6d8ba4c665a500c94eacbe42b86f2",
|
|
232
|
+
"spec/v2/core/security-defaults.md": "1d38aa03b9d2507f1d4a985333cc57ae7892cecc7356f6342fbd1672e34f638a",
|
|
233
|
+
"spec/v2/core/versioning.md": "d731782eeabb953919c7c7057340b7f9c2916b6186790e6b04c7d8ea60767189",
|
|
234
|
+
"spec/v2/core/webhooks.md": "d360da78336afa837bddf515e289e445808bca02e1e4b493f81fa51ce4a545c4",
|
|
235
|
+
"spec/v2/core/workflow-chain-packs.md": "ef2b07d2d99334eaec0cc370314e2ad2f84ea58deb699e5d92e6e52d6056613f",
|
|
236
|
+
"spec/v2/declaration.json": "63dd351a87439241bc744ce62fdc498b7621292c11a4ad42c3f41429d72366b9",
|
|
236
237
|
"spec/v2/declaration.schema.json": "eac5f8080bd572f147bd57d0c4ac8ad73b14536d9236222490ae29a33b21e184",
|
|
237
238
|
"spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
|
|
238
239
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
@@ -262,14 +263,15 @@
|
|
|
262
263
|
"spec/v2/facets/interrupt.schema.json": "c76c58c0abd99de619f8c8afad246568f5bc9cce3b7a0d097d16d87eba84be3a",
|
|
263
264
|
"spec/v2/facets/mcp.schema.json": "f56609fe24ba6160ad29ae31aa16043354ff005e5cb6a0f3871c4d226087cc08",
|
|
264
265
|
"spec/v2/facets/replay.schema.json": "0a5aefb0108c708c6794fadd533cf256709704c4f16dbf44cd63fa8980c78018",
|
|
266
|
+
"spec/v2/facets/runList.schema.json": "6223e1baaebbb9f4faa71bfe968f8d22b4aa88046d0e43e8d5f56409e7f86e90",
|
|
265
267
|
"spec/v2/facets/sandbox.schema.json": "e75f54486d387b2d089791aa93b5093cd1450f1920753a0f97fc23acdfe978ed",
|
|
266
268
|
"spec/v2/facets/webhooks.schema.json": "0b766033ef4b4c6c2ac6f9979a19843ba024d3a1d1576972dcb55aaba52f1b9a",
|
|
267
269
|
"spec/v2/id-field-bindings.json": "d9d1f8e266660b581f11020b9511a65c677e026b1a71ada870682ad77e18555d",
|
|
268
|
-
"spec/v2/path-manifest.json": "
|
|
270
|
+
"spec/v2/path-manifest.json": "a6f4e652e866889e8bde815874062f62929aee95dc8e58e7152c56adee262163",
|
|
269
271
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
270
272
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
271
|
-
"spec/v2/release.json": "
|
|
273
|
+
"spec/v2/release.json": "e67c0ef31e033c4ea9ed379deec2efa294d2ca6ba194913d535b67341a946224",
|
|
272
274
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
273
275
|
},
|
|
274
|
-
"corpusCommit": "
|
|
276
|
+
"corpusCommit": "fb69952ca7d4331c3744f8a17ffe624a89508050"
|
|
275
277
|
}
|
|
@@ -280,3 +280,47 @@ export function resolveRegistrationUrl(localUrl: string): { url: string; tunnell
|
|
|
280
280
|
|
|
281
281
|
return { url: raw, tunnelled: true };
|
|
282
282
|
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Where a scenario's own webhook receiver should LISTEN, and what address the
|
|
286
|
+
* host under test should be told to POST to.
|
|
287
|
+
*
|
|
288
|
+
* Default (variable unset) is `127.0.0.1` for both, which is what every receiver
|
|
289
|
+
* in this suite hard-coded before this helper existed. That default is
|
|
290
|
+
* deliberate and must stay: in the ordinary in-process posture the host is in
|
|
291
|
+
* this very process, loopback is the correct and narrowest binding, and nothing
|
|
292
|
+
* outside the process has any business reaching a test receiver. This helper
|
|
293
|
+
* never widens that.
|
|
294
|
+
*
|
|
295
|
+
* `OPENWOP_CONFORMANCE_HARNESS_HOST` is the operator declaring the opposite —
|
|
296
|
+
* that the host under test runs somewhere `127.0.0.1` does not name this
|
|
297
|
+
* process (a container, a VM, another box) and that the given name is how it
|
|
298
|
+
* reaches this machine. Only then does the receiver bind `0.0.0.0` and
|
|
299
|
+
* advertise that name.
|
|
300
|
+
*
|
|
301
|
+
* MEASURED 2026-09-09, which is why this exists. A container lane ran the host
|
|
302
|
+
* as a real image with `--add-host host.docker.internal:host-gateway` and its
|
|
303
|
+
* compat/OIDC doubles reachable, and `webhook-signed-delivery` plus
|
|
304
|
+
* `replay-fanout-suppression` still failed. From inside the container:
|
|
305
|
+
*
|
|
306
|
+
* "msg":"webhook delivery failed","url":"http://127.0.0.1:55469/",
|
|
307
|
+
* "detail":"fetch failed (connect ECONNREFUSED 127.0.0.1:55469)"
|
|
308
|
+
*
|
|
309
|
+
* The address named the CONTAINER's loopback. Two independent blockers, both
|
|
310
|
+
* this suite's: the receiver advertised `127.0.0.1`, and it bound loopback-only
|
|
311
|
+
* so no external name could have reached it either. The lane was configured
|
|
312
|
+
* correctly and the host signs webhooks correctly; the scenarios simply could
|
|
313
|
+
* not be witnessed, and recorded a plain `fail` while doing it — a false
|
|
314
|
+
* accusation, and the mirror of a gate that cannot fail.
|
|
315
|
+
*
|
|
316
|
+
* This does NOT relax any SSRF gate. `host.docker.internal` is still a private
|
|
317
|
+
* address over plain `http`, so the three gates in `webhook-signed-delivery`'s
|
|
318
|
+
* docblock apply unchanged and a host must still opt in (or use
|
|
319
|
+
* `OPENWOP_WEBHOOK_RECEIVER_URL`, which waives nothing) to witness the row.
|
|
320
|
+
* All this fixes is that the packet had nowhere to go.
|
|
321
|
+
*/
|
|
322
|
+
export function receiverBinding(): { bind: string; advertise: string } {
|
|
323
|
+
const advertise = process.env['OPENWOP_CONFORMANCE_HARNESS_HOST']?.trim();
|
|
324
|
+
if (!advertise) return { bind: '127.0.0.1', advertise: '127.0.0.1' };
|
|
325
|
+
return { bind: '0.0.0.0', advertise };
|
|
326
|
+
}
|
|
@@ -76,7 +76,7 @@ import { forkDeclined } from '../lib/fork-availability.js';
|
|
|
76
76
|
import { discoveryFamilies, readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
77
77
|
import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
78
78
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
79
|
-
import { discoverOwnedTenant } from '../lib/webhook-receiver.js';
|
|
79
|
+
import { discoverOwnedTenant, receiverBinding } from '../lib/webhook-receiver.js';
|
|
80
80
|
import { recordRequirement } from '../lib/requirement-ledger.js';
|
|
81
81
|
import { requirementIdForFile } from '../lib/scenario-disposition.js';
|
|
82
82
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -118,10 +118,11 @@ async function startReceiver(): Promise<{ server: Server; url: string; received:
|
|
|
118
118
|
res.end();
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
|
-
|
|
121
|
+
const binding = receiverBinding();
|
|
122
|
+
await new Promise<void>((resolve) => server.listen(0, binding.bind, () => resolve()));
|
|
122
123
|
const addr = server.address();
|
|
123
124
|
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
124
|
-
return { server, url: `http
|
|
125
|
+
return { server, url: `http://${binding.advertise}:${addr.port}/`, received };
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
let activeServer: Server | null = null;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0182 — `run-list` (suite 2.1.0, target major 2; gated on the `runList`
|
|
3
|
+
* family; `spec/v2/core/runs.md` §List).
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — gated. A host that advertises `runList` MUST
|
|
6
|
+
* serve `GET /runs` as a tenant-scoped, cursor-paginated list of the caller's
|
|
7
|
+
* runs: two runs the suite just created appear (§A.2), every `runId` in the
|
|
8
|
+
* body carries the caller's tenant segment — the segment of the ids the host
|
|
9
|
+
* minted for this credential moments ago (§A.2), no page exceeds the advertised
|
|
10
|
+
* `maxPageSize` (§A.3), a cursor the host did not mint is refused
|
|
11
|
+
* `400 validation_error` (§A.3), and when the `filters` facet names
|
|
12
|
+
* `workflowId` the filtered list contains only that workflow (§A.4). The body
|
|
13
|
+
* is validated against `run-list-response.schema.json`, which `$ref`s
|
|
14
|
+
* `run-snapshot.schema.json` — so a bare id anywhere fails the shape leg
|
|
15
|
+
* before it fails the tenant leg.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest';
|
|
19
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
20
|
+
import { gateFamily, v2Discovery, v2Validator } from '../lib/v2.js';
|
|
21
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
22
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
23
|
+
import { req } from '../lib/requirement-ids.js';
|
|
24
|
+
|
|
25
|
+
const DOC = 'spec/v2/core/runs.md §List';
|
|
26
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
27
|
+
const MAX_PAGES = 50;
|
|
28
|
+
|
|
29
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
30
|
+
try { return await fn(); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function createRun(): Promise<{ runId: string } | { reason: string }> {
|
|
34
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
35
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
36
|
+
const runId = (res.json as { runId?: unknown } | undefined)?.runId;
|
|
37
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${res.status} ${readErrorCode(res.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
|
|
38
|
+
return { runId };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function tenantOf(runId: string): string { return runId.slice(0, runId.indexOf('/')); }
|
|
42
|
+
|
|
43
|
+
type Page = { runs?: unknown; nextCursor?: unknown };
|
|
44
|
+
|
|
45
|
+
/** Walk the list from the first page, collecting run ids, until the last page or MAX_PAGES. */
|
|
46
|
+
async function walk(query: string, maxPageSize: number): Promise<{ ids: string[]; pages: Page[]; reason?: string }> {
|
|
47
|
+
const ids: string[] = []; const pages: Page[] = []; let cursor: string | undefined;
|
|
48
|
+
for (let i = 0; i < MAX_PAGES; i++) {
|
|
49
|
+
const q = `${query}${query ? '&' : '?'}limit=${maxPageSize}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
|
50
|
+
const res = await http(() => driver.get(`/runs${q}`));
|
|
51
|
+
if (res === null) return { ids, pages, reason: `GET /runs${q} unreachable (fetch failed)` };
|
|
52
|
+
if (res.status !== 200) return { ids, pages, reason: `GET /runs${q} answered ${res.status} ${readErrorCode(res.json) ?? ''}` };
|
|
53
|
+
const page = res.json as Page; pages.push(page);
|
|
54
|
+
for (const r of Array.isArray(page.runs) ? page.runs : []) { const id = (r as { runId?: unknown }).runId; if (typeof id === 'string') ids.push(id); }
|
|
55
|
+
if (typeof page.nextCursor !== 'string') break;
|
|
56
|
+
cursor = page.nextCursor;
|
|
57
|
+
}
|
|
58
|
+
return { ids, pages };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('RFC 0182 — run-list (gated on runList)', () => {
|
|
62
|
+
it('two runs the caller just created appear, every id is bound to the caller\'s tenant, and no page exceeds maxPageSize', async () => {
|
|
63
|
+
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
64
|
+
const fam = await gateFamily('runList');
|
|
65
|
+
if (!fam) return softSkip('inapplicable', 'runList family not advertised — no obligation (gate recorded under openwop.family.runList)');
|
|
66
|
+
const maxPageSize = typeof fam['maxPageSize'] === 'number' ? fam['maxPageSize'] : NaN;
|
|
67
|
+
expect(Number.isInteger(maxPageSize) && maxPageSize >= 1, req('openwop.requirement.0182.run-list.tenant-scoped', 'spec/v2/core/capabilities.md § runList', `runList.maxPageSize MUST be an integer ≥ 1 (got ${String(fam['maxPageSize'])})`)).toBe(true);
|
|
68
|
+
const a = await createRun(); if ('reason' in a) return softSkip('blocked', a.reason);
|
|
69
|
+
const b = await createRun(); if ('reason' in b) return softSkip('blocked', b.reason);
|
|
70
|
+
const tenant = tenantOf(a.runId);
|
|
71
|
+
const walked = await walk('', maxPageSize);
|
|
72
|
+
if (walked.reason && walked.pages.length === 0) return softSkip('blocked', walked.reason);
|
|
73
|
+
const validate = v2Validator('run-list-response');
|
|
74
|
+
for (const page of walked.pages) {
|
|
75
|
+
const v = validate(page);
|
|
76
|
+
expect(v.ok, req('openwop.requirement.0182.run-list.tenant-scoped', DOC, `every page MUST validate against run-list-response.schema.json — runs[] of RunSnapshot (bound runId) plus optional nextCursor (${v.errors})`)).toBe(true);
|
|
77
|
+
expect((page.runs as unknown[]).length <= maxPageSize, req('openwop.requirement.0182.run-list.tenant-scoped', DOC, `a page MUST NOT exceed the advertised runList.maxPageSize (${maxPageSize}); got ${(page.runs as unknown[]).length}`)).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
expect(walked.ids.includes(a.runId) && walked.ids.includes(b.runId), req('openwop.requirement.0182.run-list.tenant-scoped', DOC, `a run the caller just created MUST appear in its unfiltered list — created ${a.runId} and ${b.runId}; walked ${walked.ids.length} id(s) over ${walked.pages.length} page(s)${walked.reason ? ` (${walked.reason})` : ''}`)).toBe(true);
|
|
80
|
+
const foreign = walked.ids.filter((id) => tenantOf(id) !== tenant);
|
|
81
|
+
expect(foreign, req('openwop.requirement.0182.run-list.tenant-scoped', 'spec/v2/core/identity.md §5', `every runId in the list MUST carry the caller's tenant segment (${tenant}) — the list is tenant-scoped by construction; found ${foreign.length} other(s): ${foreign.slice(0, 3).join(', ')}`)).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('a cursor the host did not mint is refused 400 validation_error', async () => {
|
|
85
|
+
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
86
|
+
if (!(await gateFamily('runList'))) return softSkip('inapplicable', 'runList family not advertised — no obligation (gate recorded under openwop.family.runList)');
|
|
87
|
+
const res = await http(() => driver.get('/runs?cursor=openwop-conformance-not-a-cursor'));
|
|
88
|
+
if (res === null) return softSkip('blocked', 'GET /runs?cursor=… unreachable (fetch failed)');
|
|
89
|
+
expect(res.status, req('openwop.requirement.0182.run-list.cursor-refused', DOC, `a cursor the host did not mint MUST be refused 400 validation_error; got ${res.status} ${readErrorCode(res.json) ?? ''}`)).toBe(400);
|
|
90
|
+
expect(readErrorCode(res.json), req('openwop.requirement.0182.run-list.cursor-refused', 'spec/v2/core/errors.md', 'the refusal MUST carry validation_error')).toBe('validation_error');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('when the filters facet names workflowId, a filtered list contains only that workflow', async () => {
|
|
94
|
+
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
95
|
+
const fam = await gateFamily('runList');
|
|
96
|
+
if (!fam) return softSkip('inapplicable', 'runList family not advertised — no obligation (gate recorded under openwop.family.runList)');
|
|
97
|
+
const filters = Array.isArray(fam['filters']) ? (fam['filters'] as unknown[]) : [];
|
|
98
|
+
if (!filters.includes('workflowId')) return softSkip('inapplicable', 'runList.filters does not name workflowId — the filter leg has no obligation');
|
|
99
|
+
const maxPageSize = typeof fam['maxPageSize'] === 'number' ? fam['maxPageSize'] : 1;
|
|
100
|
+
const c = await createRun(); if ('reason' in c) return softSkip('blocked', c.reason);
|
|
101
|
+
const walked = await walk(`?workflowId=${encodeURIComponent(NOOP_WORKFLOW_ID)}`, maxPageSize);
|
|
102
|
+
if (walked.reason && walked.pages.length === 0) return softSkip('blocked', walked.reason);
|
|
103
|
+
const others = walked.pages.flatMap((p) => (Array.isArray(p.runs) ? p.runs : [])).filter((r) => (r as { workflowId?: unknown }).workflowId !== NOOP_WORKFLOW_ID);
|
|
104
|
+
expect(others.length, req('openwop.requirement.0182.run-list.filter-exact', DOC, `a workflowId filter MUST be exact — every listed run's workflowId MUST equal ${NOOP_WORKFLOW_ID}; found ${others.length} other(s)`)).toBe(0);
|
|
105
|
+
expect(walked.ids.includes(c.runId), req('openwop.requirement.0182.run-list.filter-exact', DOC, `the filtered list MUST still contain the ${NOOP_WORKFLOW_ID} run the caller just created (${c.runId})`)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -31,7 +31,7 @@ import { afterEach, describe, it, expect } from 'vitest';
|
|
|
31
31
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
32
32
|
import { driver } from '../lib/driver.js';
|
|
33
33
|
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
34
|
-
import { resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
34
|
+
import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
35
35
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
36
36
|
import { softSkip } from '../lib/soft-skip.js';
|
|
37
37
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -74,10 +74,11 @@ async function startReceiver(failFirst: number): Promise<{ server: Server; url:
|
|
|
74
74
|
});
|
|
75
75
|
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
76
76
|
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
77
|
-
|
|
77
|
+
const binding = receiverBinding();
|
|
78
|
+
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
78
79
|
const addr = server.address();
|
|
79
80
|
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
80
|
-
return { server, url: `http
|
|
81
|
+
return { server, url: `http://${binding.advertise}:${addr.port}/`, attempts };
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
let active: Server | null = null;
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Operator contract — THREE gates, not one (clarified 2026-08-25).
|
|
20
20
|
*
|
|
21
|
-
* The test receiver is `http://127.0.0.1:{port}
|
|
22
|
-
*
|
|
21
|
+
* The test receiver is `http://127.0.0.1:{port}/` — or, where the operator has
|
|
22
|
+
* declared `OPENWOP_CONFORMANCE_HARNESS_HOST` because the host under test is
|
|
23
|
+
* off-process, `http://{that name}:{port}/` (see `receiverBinding`). Either
|
|
24
|
+
* way it is a private address over plain `http`, and `webhooks.md` forbids it
|
|
25
|
+
* three separate times. A host honoring
|
|
23
26
|
* `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` (or an equivalent opt-in) MUST
|
|
24
27
|
* relax ALL THREE for this scenario to be witnessable:
|
|
25
28
|
*
|
|
@@ -107,7 +110,7 @@ import { driver } from '../lib/driver.js';
|
|
|
107
110
|
import { discoveryFamilies } from '../lib/discovery-capabilities.js';
|
|
108
111
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
109
112
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
110
|
-
import { discoverOwnedTenant, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
113
|
+
import { discoverOwnedTenant, receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
111
114
|
import { req } from '../lib/requirement-ids.js';
|
|
112
115
|
|
|
113
116
|
interface DeliveredRequest {
|
|
@@ -141,10 +144,11 @@ async function startReceiver(): Promise<{ server: Server; url: string; received:
|
|
|
141
144
|
// pins it so `ngrok http <port>` (or a proxy) has a stable target.
|
|
142
145
|
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
143
146
|
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
144
|
-
|
|
147
|
+
const binding = receiverBinding();
|
|
148
|
+
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
145
149
|
const addr = server.address();
|
|
146
150
|
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
147
|
-
return { server, url: `http
|
|
151
|
+
return { server, url: `http://${binding.advertise}:${addr.port}/`, received };
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
let activeServer: Server | null = null;
|
|
@@ -254,9 +258,6 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
254
258
|
const runId = (create.json as { runId: string }).runId;
|
|
255
259
|
await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
256
260
|
|
|
257
|
-
// Allow a small grace period for fire-and-forget delivery to land.
|
|
258
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
259
|
-
|
|
260
261
|
// Test-isolation note: when this scenario runs concurrently with
|
|
261
262
|
// other webhook-bearing scenarios against a stateful host, the
|
|
262
263
|
// host's webhook registry fans out EVERY run's events to EVERY
|
|
@@ -264,14 +265,43 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
264
265
|
// deliveries from other tests' concurrent runs. Filter to events
|
|
265
266
|
// carrying THIS test's runId so the assertion checks the
|
|
266
267
|
// signature shape on a delivery the host emitted for THIS run.
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
268
|
+
const ours = (): DeliveredRequest[] =>
|
|
269
|
+
receiver.received.filter((d) => {
|
|
270
|
+
try {
|
|
271
|
+
const body = JSON.parse(d.body) as { runId?: unknown };
|
|
272
|
+
return body.runId === runId;
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// WAIT for a delivery, don't sleep a guessed interval for one.
|
|
279
|
+
//
|
|
280
|
+
// This was `setTimeout(500)` — a fixed grace "for fire-and-forget delivery
|
|
281
|
+
// to land". That assumes delivery is synchronous-ish with run completion,
|
|
282
|
+
// which is true of a host that POSTs inline from its event loop and false
|
|
283
|
+
// of one that does what `webhooks.md` §"Delivery" actually asks for: a
|
|
284
|
+
// durable queue with retries, drained by a worker on its own cadence.
|
|
285
|
+
//
|
|
286
|
+
// MEASURED 2026-09-09 against a reference host whose delivery worker polls
|
|
287
|
+
// every 1000ms: with the receiver address finally correct, this assertion
|
|
288
|
+
// still reported `expected 0 to be greater than 0` after a 549ms test — the
|
|
289
|
+
// sleep had expired before the worker's first tick. It reads as "the host
|
|
290
|
+
// does not deliver webhooks" when the host had not yet been asked to. The
|
|
291
|
+
// more durable a host's delivery machinery, the more exposed it is here.
|
|
292
|
+
//
|
|
293
|
+
// A deadline is the honest instrument: it still FAILS, never skips, and the
|
|
294
|
+
// failure now means "did not deliver within DELIVERY_DEADLINE_MS" rather
|
|
295
|
+
// than "did not deliver within one arbitrary tick of an unrelated clock".
|
|
296
|
+
// Sized to stay inside the 30s per-test budget alongside the 10s terminal
|
|
297
|
+
// poll above, so a slow host fails on THIS assertion's message rather than
|
|
298
|
+
// on an uninformative vitest timeout.
|
|
299
|
+
const DELIVERY_DEADLINE_MS = 12_000;
|
|
300
|
+
const deadline = Date.now() + DELIVERY_DEADLINE_MS;
|
|
301
|
+
while (ours().length === 0 && Date.now() < deadline) {
|
|
302
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
303
|
+
}
|
|
304
|
+
const ourDeliveries = ours();
|
|
275
305
|
// With a tunnel wired this is the assertion that keeps a mis-wired front
|
|
276
306
|
// from reading as a pass. Registration succeeded, so the host believes it
|
|
277
307
|
// has a subscriber; if nothing arrived HERE, the delivery went somewhere
|
|
@@ -284,7 +314,8 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
284
314
|
'Registration was accepted, so zero deliveries observed on the local receiver means ' +
|
|
285
315
|
'either the host did not deliver, or OPENWOP_WEBHOOK_RECEIVER_URL does not actually ' +
|
|
286
316
|
'front this process. Both are failures; neither is a skip.'
|
|
287
|
-
: 'host MUST POST at least one event for THIS run to a registered subscriber
|
|
317
|
+
: 'host MUST POST at least one event for THIS run to a registered subscriber within '
|
|
318
|
+
+ `${DELIVERY_DEADLINE_MS}ms of run.completed`,
|
|
288
319
|
)).toBeGreaterThan(0);
|
|
289
320
|
|
|
290
321
|
// Validate the FIRST delivery's signature contract. Other deliveries
|