@openwop/openwop-conformance 1.136.11 → 1.139.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +268 -3
- package/coverage.md +2 -2
- package/dist/cli.js +8 -1
- package/dist/lib/requirement-ledger.js +8 -0
- package/dist/lib/scenario-disposition.js +30 -0
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/capabilities.schema.json +1 -1
- package/schemas/certification-bundle-v2.schema.json +11 -1
- package/src/cli.ts +8 -1
- package/src/global-setup.ts +169 -0
- package/src/lib/fork-availability.test.ts +69 -0
- package/src/lib/fork-availability.ts +61 -0
- package/src/lib/global-setup.test.ts +76 -0
- package/src/lib/polling.test.ts +80 -0
- package/src/lib/polling.ts +39 -2
- package/src/lib/requirement-ledger.test.ts +75 -0
- package/src/lib/requirement-ledger.ts +9 -0
- package/src/lib/risk-disposition.test.ts +91 -0
- package/src/lib/scenario-disposition.ts +30 -0
- package/src/lib/webhook-receiver.test.ts +76 -0
- package/src/lib/webhook-receiver.ts +24 -3
- package/src/scenarios/a2a-task-roundtrip.test.ts +66 -10
- package/src/scenarios/agent-channel-dispatch.test.ts +3 -3
- package/src/scenarios/conformance-execution-witness.test.ts +29 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +10 -6
- package/src/scenarios/durability-poison-exhaustion.test.ts +154 -0
- package/src/scenarios/replay-fanout-suppression.test.ts +328 -0
- package/src/scenarios/replay-fork-arbitrary.test.ts +9 -3
- package/src/scenarios/replay-fork.test.ts +10 -2
- package/src/scenarios/replay-side-effect-suppression.test.ts +3 -2
- package/src/scenarios/replayDeterminism.test.ts +8 -6
- package/src/scenarios/webhook-receiver-adversarial.test.ts +9 -3
- package/src/scenarios/webhook-signed-delivery.test.ts +76 -13
- package/src/setup.ts +24 -5
- package/vitest.config.ts +7 -0
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
import { describe, it, expect } from 'vitest';
|
|
20
20
|
import { driver } from '../lib/driver.js';
|
|
21
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
21
23
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
22
24
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
23
25
|
|
|
@@ -69,7 +71,7 @@ function structuralShape(events: readonly RawEvent[]): Array<{ type: unknown; no
|
|
|
69
71
|
describe('replay-determinism: openwop-replay-fork profile gate', () => {
|
|
70
72
|
it('host advertising replay.supported MUST also advertise replay.modes', async () => {
|
|
71
73
|
const replay = await fetchReplayCapability();
|
|
72
|
-
if (replay === null || replay.supported !== true) return
|
|
74
|
+
if (replay === null || replay.supported !== true) return softSkip('inapplicable', 'host does not advertise `replay.supported: true` — the replay contract does not apply to it');
|
|
73
75
|
|
|
74
76
|
expect(Array.isArray(replay.modes), driver.describe(
|
|
75
77
|
'spec/v1/replay.md',
|
|
@@ -91,8 +93,8 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
|
|
|
91
93
|
'two replay forks of the same point produce structurally-identical event lists',
|
|
92
94
|
async () => {
|
|
93
95
|
const replay = await fetchReplayCapability();
|
|
94
|
-
if (replay === null || replay.supported !== true) return
|
|
95
|
-
if (!Array.isArray(replay.modes) || !replay.modes.includes('replay')) return
|
|
96
|
+
if (replay === null || replay.supported !== true) return softSkip('inapplicable', 'host does not advertise `replay.supported: true` — the replay contract does not apply to it');
|
|
97
|
+
if (!Array.isArray(replay.modes) || !replay.modes.includes('replay')) return softSkip('inapplicable', 'host advertises replay but not the `replay` mode — this leg is out of scope for it');
|
|
96
98
|
|
|
97
99
|
// Phase 1: complete an original run.
|
|
98
100
|
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
@@ -105,7 +107,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
|
|
|
105
107
|
mode: 'replay',
|
|
106
108
|
fromSeq: 0,
|
|
107
109
|
});
|
|
108
|
-
if (fork1.status
|
|
110
|
+
if (forkDeclined(fork1.status, 'determinism fork 1')) return;
|
|
109
111
|
expect(fork1.status, driver.describe(
|
|
110
112
|
'spec/v1/replay.md',
|
|
111
113
|
'POST /v1/runs/{runId}:fork with mode=replay MUST return 201',
|
|
@@ -118,7 +120,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
|
|
|
118
120
|
mode: 'replay',
|
|
119
121
|
fromSeq: 0,
|
|
120
122
|
});
|
|
121
|
-
if (fork2.status
|
|
123
|
+
if (forkDeclined(fork2.status, 'determinism fork 2')) return;
|
|
122
124
|
expect(fork2.status).toBe(201);
|
|
123
125
|
const fork2Id = (fork2.json as { runId: string }).runId;
|
|
124
126
|
await pollUntilTerminal(fork2Id, { timeoutMs: 10_000 });
|
|
@@ -152,7 +154,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
|
|
|
152
154
|
describe.skipIf(SKIP_NO_NOOP)('replay-determinism: branch-mode is permitted to diverge', () => {
|
|
153
155
|
it('branch mode does NOT need to produce identical event sequences (negative-control)', async () => {
|
|
154
156
|
const replay = await fetchReplayCapability();
|
|
155
|
-
if (replay === null || replay.supported !== true) return;
|
|
157
|
+
if (replay === null || replay.supported !== true) return softSkip('inapplicable', 'host does not advertise `replay.supported: true` — the replay contract does not apply to it');
|
|
156
158
|
if (!Array.isArray(replay.modes) || !replay.modes.includes('branch')) return;
|
|
157
159
|
|
|
158
160
|
// Self-test on the spec interpretation: branch and replay are
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* delivers body B'. Receiver MUST reject with
|
|
18
18
|
* `signature_mismatch`.
|
|
19
19
|
* 2. Tampered HMAC — body is valid; adversary flips a byte of the
|
|
20
|
-
*
|
|
20
|
+
* sha256=<hex> signature. Receiver MUST reject with
|
|
21
21
|
* `signature_mismatch`.
|
|
22
22
|
* 3. Stale timestamp — body + HMAC are valid but timestamp is
|
|
23
23
|
* older than the default 5-minute window. Receiver MUST reject
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
createReceiverState,
|
|
43
43
|
signPayload,
|
|
44
44
|
verifyWebhookDelivery,
|
|
45
|
+
SIGNATURE_PREFIX,
|
|
45
46
|
} from '../lib/webhook-receiver.js';
|
|
46
47
|
|
|
47
48
|
describe('webhook-receiver-adversarial: receiver rejects five canonical attacks', () => {
|
|
@@ -93,8 +94,13 @@ describe('webhook-receiver-adversarial: receiver rejects five canonical attacks'
|
|
|
93
94
|
it('case 2: tampered HMAC → signature_mismatch', () => {
|
|
94
95
|
const state = createReceiverState();
|
|
95
96
|
const { signatureHeader, timestampHeader, algorithmHeader } = signPayload(secret, ts, body);
|
|
96
|
-
// Flip one hex character of the
|
|
97
|
-
|
|
97
|
+
// Flip one hex character of the `sha256=<hex>` signature. Derived from
|
|
98
|
+
// SIGNATURE_PREFIX rather than hard-coded: the literal `5` was inside the
|
|
99
|
+
// hex only while the prefix was the (non-spec) `v1=`, and silently moved
|
|
100
|
+
// INTO the prefix when it was corrected to `sha256=` — turning a
|
|
101
|
+
// tampered-HMAC case into a malformed-header one. The test caught it; the
|
|
102
|
+
// constant is what makes it stay caught.
|
|
103
|
+
const flipIndex = SIGNATURE_PREFIX.length + 2;
|
|
98
104
|
const orig = signatureHeader[flipIndex]!;
|
|
99
105
|
const replacement = orig === '0' ? '1' : '0';
|
|
100
106
|
const tampered = signatureHeader.slice(0, flipIndex) + replacement + signatureHeader.slice(flipIndex + 1);
|
|
@@ -7,18 +7,68 @@
|
|
|
7
7
|
* 2. `X-openwop-Signature-Algorithm: v1` header is present.
|
|
8
8
|
* 3. `X-openwop-Signature` is a valid HMAC-SHA256 of
|
|
9
9
|
* `${timestamp}.${rawBody}` under the subscription secret.
|
|
10
|
-
* 4. `X-openwop-
|
|
10
|
+
* 4. `X-openwop-Webhook-Id` matches the returned `webhookId`.
|
|
11
|
+
* (This line said `X-openwop-Subscription-Id` until 2026-08-19, two years
|
|
12
|
+
* after the assertion below stopped checking that header — a docblock
|
|
13
|
+
* asserting more than the code did, in the file that documents a
|
|
14
|
+
* security-relevant contract.)
|
|
11
15
|
*
|
|
12
16
|
* Capability-gated: skips when the host does not advertise
|
|
13
17
|
* `capabilities.webhooks.supported = true`.
|
|
14
18
|
*
|
|
15
|
-
* Operator contract
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true`
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Operator contract — THREE gates, not one (clarified 2026-08-25).
|
|
20
|
+
*
|
|
21
|
+
* The test receiver is `http://127.0.0.1:{port}/`, and `webhooks.md`
|
|
22
|
+
* forbids it three separate times. A host honoring
|
|
23
|
+
* `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` (or an equivalent opt-in) MUST
|
|
24
|
+
* relax ALL THREE for this scenario to be witnessable:
|
|
25
|
+
*
|
|
26
|
+
* 1. **Scheme.** §"SSRF protection" bullet 1 rejects non-`https://`
|
|
27
|
+
* protocols, and §"Register" says `url` MUST be `https://`. The
|
|
28
|
+
* receiver is plain `http`. This gate is the one the flag's
|
|
29
|
+
* description omitted until 2026-08-25, and it fires FIRST on a
|
|
30
|
+
* host that validates scheme before address.
|
|
31
|
+
* 2. **Registration-time address check.** §"SSRF protection": the
|
|
32
|
+
* server MUST validate subscription URLs at registration time and
|
|
33
|
+
* reject loopback / RFC1918 / link-local / ULA / metadata.
|
|
34
|
+
* 3. **Delivery-time re-resolution.** §"Delivery-time egress
|
|
35
|
+
* validation (RFC 0093)": the dispatcher MUST re-resolve at
|
|
36
|
+
* delivery time and validate every resolved address against the
|
|
37
|
+
* same ranges.
|
|
38
|
+
*
|
|
39
|
+
* Gates 2 and 3 are INDEPENDENT MUSTs at different layers, so "which
|
|
40
|
+
* layer must the opt-in reach" is not a matter of taste: a relaxation
|
|
41
|
+
* reaching only one layer cannot produce a witness. Delivery-only leaves
|
|
42
|
+
* registration returning `400 webhook_url_rejected` (observed on a tier-2
|
|
43
|
+
* host, 2026-08-25); registration-only leaves the dispatcher re-resolving
|
|
44
|
+
* `127.0.0.1` and refusing to connect. A host whose opt-in reaches one
|
|
45
|
+
* layer is not non-conformant — it simply cannot witness this scenario,
|
|
46
|
+
* and that is a property of the test posture, not of its webhook signing.
|
|
47
|
+
*
|
|
48
|
+
* What that costs is more than a checkmark, and it is worth stating plainly
|
|
49
|
+
* because the cost is invisible from inside such a host. This scenario is
|
|
50
|
+
* typically the ONLY automated oracle a host has on its delivery header
|
|
51
|
+
* NAMES: a host-local delivery test almost always compares a hand-written
|
|
52
|
+
* literal in the test to a hand-written literal in the implementation, which
|
|
53
|
+
* is a mirror — typo both and it stays green — while the HMAC assertion
|
|
54
|
+
* beside it recomputes from `node:crypto` and is real. So the names go
|
|
55
|
+
* unchecked exactly where the bytes are checked well. All three reference
|
|
56
|
+
* hosts emitted invented header names for 16 days (openwop-examples#22) and
|
|
57
|
+
* the runs that would have caught it were the ones this scenario could not
|
|
58
|
+
* reach. A host that runs it is not thereby careful; it is thereby measured.
|
|
59
|
+
*
|
|
60
|
+
* Relaxing gates 2 and 3 is test-only posture. See
|
|
61
|
+
* `SECURITY/threat-model-secret-leakage.md` §4.9 for why a
|
|
62
|
+
* registration-time relaxation is the more dangerous of the two: it
|
|
63
|
+
* writes a durable subscription row that survives the flag being turned
|
|
64
|
+
* back off.
|
|
65
|
+
*
|
|
66
|
+
* When the host rejects with `400 webhook_url_rejected`, this scenario
|
|
67
|
+
* records `blocked` (RFC 0148 §A) — NOT a pass. Under a plain
|
|
68
|
+
* `vitest run` the console still prints "1 passed", because that is
|
|
69
|
+
* vitest reporting test outcomes rather than conformance dispositions;
|
|
70
|
+
* the run-end disposition summary (`src/global-setup.ts`) is where the
|
|
71
|
+
* `blocked` becomes visible without `--certify`.
|
|
22
72
|
*
|
|
23
73
|
* @see spec/v1/webhooks.md §"Signature scheme"
|
|
24
74
|
*/
|
|
@@ -122,10 +172,23 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
122
172
|
|
|
123
173
|
expect(reg.status, driver.describe(
|
|
124
174
|
'webhooks.md §"Register"',
|
|
125
|
-
'POST /v1/webhooks MUST return 201 with
|
|
175
|
+
'POST /v1/webhooks MUST return 201 with webhookId + secret on success',
|
|
126
176
|
)).toBe(201);
|
|
127
|
-
|
|
128
|
-
|
|
177
|
+
// `webhookId`, NOT `subscriptionId` (corrected 2026-08-19). `webhooks.md`
|
|
178
|
+
// §"Register" shows `{"webhookId": "wh_a3b9c2", ...}`, `api/openapi.yaml`
|
|
179
|
+
// declares the 201 body `required: [webhookId]` with no `subscriptionId`
|
|
180
|
+
// property at all, and the sibling `webhook-tenant-isolation.test.ts` reads
|
|
181
|
+
// `webhookId`. This file required a field the contract does not define, so a
|
|
182
|
+
// SPEC-CONFORMING host failed at the first assertion — the suite being
|
|
183
|
+
// different from the spec, which is worse than the stricter-than-spec case
|
|
184
|
+
// COMPATIBILITY.md §2.3 forbids. Reported by a tier-2 host emitting exactly
|
|
185
|
+
// what the spec shows. The postgres reference host returns both names, which
|
|
186
|
+
// is why nothing went red here.
|
|
187
|
+
const sub = reg.json as { webhookId: string; secret: string };
|
|
188
|
+
expect(typeof sub.webhookId, driver.describe(
|
|
189
|
+
'api/openapi.yaml registerWebhook 201',
|
|
190
|
+
'the 201 body MUST carry `webhookId` (required) — `subscriptionId` is not in the contract',
|
|
191
|
+
)).toBe('string');
|
|
129
192
|
expect(typeof sub.secret).toBe('string');
|
|
130
193
|
expect(sub.secret.length).toBeGreaterThan(0);
|
|
131
194
|
|
|
@@ -174,7 +237,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
174
237
|
expect(
|
|
175
238
|
first.headers['x-openwop-webhook-id'],
|
|
176
239
|
driver.describe('webhooks.md §"Delivery headers"', 'X-openwop-Webhook-Id MUST carry the subscription id'),
|
|
177
|
-
).toBe(sub.
|
|
240
|
+
).toBe(sub.webhookId);
|
|
178
241
|
|
|
179
242
|
const timestamp = first.headers['x-openwop-timestamp'];
|
|
180
243
|
expect(
|
|
@@ -201,7 +264,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
201
264
|
expect(event.runId).toBe(runId);
|
|
202
265
|
|
|
203
266
|
// Cleanup: unregister.
|
|
204
|
-
const del = await driver.delete(`/v1/webhooks/${encodeURIComponent(sub.
|
|
267
|
+
const del = await driver.delete(`/v1/webhooks/${encodeURIComponent(sub.webhookId)}`);
|
|
205
268
|
expect(del.status).toBeGreaterThanOrEqual(200);
|
|
206
269
|
expect(del.status).toBeLessThan(300);
|
|
207
270
|
});
|
package/src/setup.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { McpFakeServer, setMcpFakeServer } from './lib/mcp-fake-server.js';
|
|
|
32
32
|
import { A2AFakePeer, setA2AFakePeer } from './lib/a2a-fake-peer.js';
|
|
33
33
|
import { afterAll, afterEach, beforeAll, expect } from 'vitest';
|
|
34
34
|
import { basename } from 'node:path';
|
|
35
|
-
import { recordRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
|
|
35
|
+
import { recordRequirement, hasRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
|
|
36
36
|
import { requirementIdForFile, resolveFileRecord, type FileTestState } from './lib/scenario-disposition.js';
|
|
37
37
|
import { softSkipDisposition } from './lib/soft-skip.js';
|
|
38
38
|
import type { DiscoveryPayload } from './lib/profiles.js';
|
|
@@ -279,10 +279,29 @@ afterAll(({}, suite) => {
|
|
|
279
279
|
// honest bundle row and the pressure to say why both survive. The rule is
|
|
280
280
|
// `resolveFileRecord` (pinned by conformance-execution-witness.test.ts).
|
|
281
281
|
const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file));
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
282
|
+
const fileRequirementId = requirementIdForFile(file);
|
|
283
|
+
// A scenario that classified ITSELF wins outright — including its `detail` and
|
|
284
|
+
// its `assertionCount`.
|
|
285
|
+
//
|
|
286
|
+
// The `catch` below has always made the explicit record win when the two
|
|
287
|
+
// DISAGREE (`recordRequirement` throws on a conflicting disposition). It did
|
|
288
|
+
// not when they AGREE: the automatic call then reached `ledger.set` and
|
|
289
|
+
// silently replaced the scenario's own detail and count with the file-level
|
|
290
|
+
// ones. Invisible until 2026-08-19, when `resolveFileRecord` started attaching
|
|
291
|
+
// a `partial-witness:` marker — a scenario that recorded `executed-pass` for a
|
|
292
|
+
// requirement it really did exercise, in a file whose LAST leg soft-skipped,
|
|
293
|
+
// would have been stamped "may not have witnessed this" over its own explicit
|
|
294
|
+
// finding. That would inject false positives into exactly the measurement the
|
|
295
|
+
// marker exists to produce.
|
|
296
|
+
//
|
|
297
|
+
// So the comment describing this line was true of half the cases. It is true
|
|
298
|
+
// of both now.
|
|
299
|
+
if (!hasRequirement(fileRequirementId)) {
|
|
300
|
+
try {
|
|
301
|
+
recordRequirement(fileRequirementId, disposition, detail, { assertionCount });
|
|
302
|
+
} catch {
|
|
303
|
+
/* never fail a file for bookkeeping */
|
|
304
|
+
}
|
|
286
305
|
}
|
|
287
306
|
_fileStates.delete(file);
|
|
288
307
|
_fileAssertions.delete(file);
|
package/vitest.config.ts
CHANGED
|
@@ -17,5 +17,12 @@ export default defineConfig({
|
|
|
17
17
|
// populates `lib/fixtures.ts` so `describe.skipIf(...)` predicates
|
|
18
18
|
// see the cached set when scenarios register their tests.
|
|
19
19
|
setupFiles: ['src/setup.ts'],
|
|
20
|
+
// RFC 0148 §A run-end disposition summary. `setup.ts` records an honest
|
|
21
|
+
// disposition per scenario file on EVERY run; before this, only
|
|
22
|
+
// `--certify` set OPENWOP_LEDGER_PATH, so on a plain `vitest run` the
|
|
23
|
+
// recording was computed and then discarded, leaving "N passed" as the
|
|
24
|
+
// sole artifact for rows the suite had classified `blocked`. This routes
|
|
25
|
+
// the ledger to a temp file and prints what did not witness.
|
|
26
|
+
globalSetup: ['src/global-setup.ts'],
|
|
20
27
|
},
|
|
21
28
|
});
|