@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
package/src/lib/polling.ts
CHANGED
|
@@ -6,7 +6,25 @@
|
|
|
6
6
|
* Polling is the lowest-common-denominator wire; SSE-specific scenarios
|
|
7
7
|
* live in stream-modes.test.ts.
|
|
8
8
|
*
|
|
9
|
-
* Bound long polls with OPENWOP_LIFECYCLE_TIMEOUT_MS env var (default 10s)
|
|
9
|
+
* Bound long polls with OPENWOP_LIFECYCLE_TIMEOUT_MS env var (default 10s) —
|
|
10
|
+
* but note what that knob can and cannot reach. It supplies the DEFAULT only,
|
|
11
|
+
* so it has no effect on the ~110 call sites that pass an explicit `timeoutMs`
|
|
12
|
+
* (59 of them passing the same `10_000` the default already was). An operator
|
|
13
|
+
* measuring a host on a cold or contended endpoint would set the documented
|
|
14
|
+
* variable, observe no change in those scenarios, and record a failure that
|
|
15
|
+
* measured the environment rather than the host.
|
|
16
|
+
*
|
|
17
|
+
* `OPENWOP_POLL_TIMEOUT_SCALE` (default `1`) closes that: it multiplies EVERY
|
|
18
|
+
* poll bound, explicit or default. Scaling rather than flooring is deliberate —
|
|
19
|
+
* a floor would flatten the deliberately-short bounds (`100`, `1000`) that some
|
|
20
|
+
* negative assertions depend on, while a scale preserves every call site's
|
|
21
|
+
* intent relative to the others. At the default it is a no-op, so no existing
|
|
22
|
+
* measurement moves.
|
|
23
|
+
*
|
|
24
|
+
* Neither knob is a way to make a hanging host pass: the assertion is that a
|
|
25
|
+
* terminal state is REACHED, and a host that never reaches one fails at any
|
|
26
|
+
* bound. What they buy is the ability to say whether a timeout measured the
|
|
27
|
+
* host or the harness.
|
|
10
28
|
*/
|
|
11
29
|
|
|
12
30
|
import { driver } from './driver.js';
|
|
@@ -33,6 +51,25 @@ export interface RunSnapshot {
|
|
|
33
51
|
const POLL_INTERVAL_MS = 250;
|
|
34
52
|
const DEFAULT_TIMEOUT_MS = Number(process.env.OPENWOP_LIFECYCLE_TIMEOUT_MS ?? 10_000);
|
|
35
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Multiplier applied to every poll bound (see the module docstring). Invalid,
|
|
56
|
+
* non-positive, or non-finite values fall back to `1` rather than silently
|
|
57
|
+
* producing a zero or negative deadline — a mis-set knob must not turn every
|
|
58
|
+
* poll into an instant failure that looks like a host defect.
|
|
59
|
+
*/
|
|
60
|
+
function pollTimeoutScale(): number {
|
|
61
|
+
const raw = process.env.OPENWOP_POLL_TIMEOUT_SCALE;
|
|
62
|
+
if (raw === undefined || raw === '') return 1;
|
|
63
|
+
const n = Number(raw);
|
|
64
|
+
return Number.isFinite(n) && n > 0 ? n : 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Apply the scale to a bound, rounding up so a scale of 1 is exactly a no-op. */
|
|
68
|
+
export function scaledTimeoutMs(timeoutMs: number): number {
|
|
69
|
+
const scale = pollTimeoutScale();
|
|
70
|
+
return scale === 1 ? timeoutMs : Math.ceil(timeoutMs * scale);
|
|
71
|
+
}
|
|
72
|
+
|
|
36
73
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
37
74
|
|
|
38
75
|
export async function getRun(runId: string): Promise<RunSnapshot> {
|
|
@@ -48,7 +85,7 @@ export async function pollUntil(
|
|
|
48
85
|
predicate: (snap: RunSnapshot) => boolean,
|
|
49
86
|
opts: { timeoutMs?: number; label?: string } = {},
|
|
50
87
|
): Promise<RunSnapshot> {
|
|
51
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
88
|
+
const timeoutMs = scaledTimeoutMs(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
52
89
|
const deadline = Date.now() + timeoutMs;
|
|
53
90
|
let last: RunSnapshot | null = null;
|
|
54
91
|
while (Date.now() < deadline) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `requirement-ledger.ts` — recording precedence.
|
|
3
|
+
*
|
|
4
|
+
* These pin the property `setup.ts` relies on when it decides whether to write
|
|
5
|
+
* its automatic file-level record: a scenario that classified ITSELF must win
|
|
6
|
+
* outright. The comment on that line claimed as much for years while the code
|
|
7
|
+
* only delivered it on DISAGREEMENT — a same-disposition re-record reached
|
|
8
|
+
* `ledger.set` and replaced the scenario's own `detail` and `assertionCount`
|
|
9
|
+
* with the file-level ones. Harmless while details were rarely set on a pass;
|
|
10
|
+
* visible the moment `resolveFileRecord` began attaching a `partial-witness:`
|
|
11
|
+
* marker, which would have stamped "may not have witnessed this" over a
|
|
12
|
+
* scenario's own explicit finding.
|
|
13
|
+
*
|
|
14
|
+
* @see requirement-ledger.ts, setup.ts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
18
|
+
import {
|
|
19
|
+
recordRequirement,
|
|
20
|
+
hasRequirement,
|
|
21
|
+
entryOf,
|
|
22
|
+
dispositionOf,
|
|
23
|
+
resetLedger,
|
|
24
|
+
} from './requirement-ledger.js';
|
|
25
|
+
|
|
26
|
+
const ID = 'openwop.scenario.ledger-precedence-fixture';
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
resetLedger();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('requirement-ledger: recording precedence', () => {
|
|
33
|
+
it('hasRequirement distinguishes "not recorded" from "recorded", which dispositionOf cannot', () => {
|
|
34
|
+
// `dispositionOf` folds the absent case to `blocked`, so it reads the same
|
|
35
|
+
// for a requirement nobody touched and one deliberately recorded blocked.
|
|
36
|
+
expect(hasRequirement(ID)).toBe(false);
|
|
37
|
+
expect(dispositionOf(ID)).toBe('blocked');
|
|
38
|
+
|
|
39
|
+
recordRequirement(ID, 'blocked', 'seam absent');
|
|
40
|
+
expect(hasRequirement(ID)).toBe(true);
|
|
41
|
+
expect(dispositionOf(ID)).toBe('blocked');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('a same-disposition re-record OVERWRITES detail and assertionCount — the reason setup.ts must guard', () => {
|
|
45
|
+
recordRequirement(ID, 'executed-pass', 'witnessed the MUST NOT on the wire', { assertionCount: 9 });
|
|
46
|
+
expect(entryOf(ID).detail).toBe('witnessed the MUST NOT on the wire');
|
|
47
|
+
|
|
48
|
+
// No throw: `recordRequirement` only rejects a CONFLICTING disposition.
|
|
49
|
+
recordRequirement(ID, 'executed-pass', 'partial-witness: inapplicable: branch leg skipped', { assertionCount: 2 });
|
|
50
|
+
expect(entryOf(ID).detail).toBe('partial-witness: inapplicable: branch leg skipped');
|
|
51
|
+
expect(entryOf(ID).assertionCount).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('a CONFLICTING disposition throws, which is how the explicit record already won on disagreement', () => {
|
|
55
|
+
recordRequirement(ID, 'executed-pass', undefined, { assertionCount: 4 });
|
|
56
|
+
expect(() => recordRequirement(ID, 'blocked', 'file-level fold said blocked')).toThrow(/already recorded/);
|
|
57
|
+
// The first recording survives the rejected second one.
|
|
58
|
+
expect(dispositionOf(ID)).toBe('executed-pass');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('guarding on hasRequirement preserves the explicit record in BOTH directions', () => {
|
|
62
|
+
// This is precisely what setup.ts now does before its automatic write.
|
|
63
|
+
recordRequirement(ID, 'executed-pass', 'witnessed the MUST NOT on the wire', { assertionCount: 9 });
|
|
64
|
+
if (!hasRequirement(ID)) {
|
|
65
|
+
recordRequirement(ID, 'executed-pass', 'partial-witness: inapplicable: branch leg skipped', { assertionCount: 2 });
|
|
66
|
+
}
|
|
67
|
+
expect(entryOf(ID).detail).toBe('witnessed the MUST NOT on the wire');
|
|
68
|
+
expect(entryOf(ID).assertionCount).toBe(9);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('still refuses a non-pass disposition with no reason — an unactionable row', () => {
|
|
72
|
+
expect(() => recordRequirement(ID, 'blocked', ' ')).toThrow(/without a reason/);
|
|
73
|
+
expect(hasRequirement(ID)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -158,6 +158,15 @@ export function readLedgerFile(path: string): readonly LedgerEntry[] {
|
|
|
158
158
|
* returned early, threw and swallowed, or was never written leaves no entry, and
|
|
159
159
|
* the honest reading of no entry is "this was not exercised".
|
|
160
160
|
*/
|
|
161
|
+
/**
|
|
162
|
+
* Has this requirement already been recorded in THIS run? Distinguishes "the
|
|
163
|
+
* scenario classified itself" from "nothing has been recorded yet" — which
|
|
164
|
+
* `dispositionOf` cannot, since it folds the absent case to `blocked`.
|
|
165
|
+
*/
|
|
166
|
+
export function hasRequirement(requirementId: string): boolean {
|
|
167
|
+
return ledger.has(requirementId);
|
|
168
|
+
}
|
|
169
|
+
|
|
161
170
|
export function dispositionOf(requirementId: string): Disposition {
|
|
162
171
|
return ledger.get(requirementId)?.disposition ?? 'blocked';
|
|
163
172
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the closed/transferred predicate in
|
|
3
|
+
* `scripts/generate-assurance-status.mjs`.
|
|
4
|
+
*
|
|
5
|
+
* This predicate is not cosmetic. Project-wide gates have been keyed to the
|
|
6
|
+
* open-Critical count it produces, so a false closure silently loosens a
|
|
7
|
+
* constraint — and a false open silently keeps one in force.
|
|
8
|
+
*
|
|
9
|
+
* It previously matched the bare substring `closed` anywhere in a row's status
|
|
10
|
+
* cell, which produced two failures in opposite directions:
|
|
11
|
+
*
|
|
12
|
+
* · RFC 0151 R1 ("Compensation executes twice", Critical) reads
|
|
13
|
+
* "Open — ... unwitnessed" and was counted CLOSED from 2026-08-16 onward,
|
|
14
|
+
* because the cell mentions "(G1 closed 2026-08-16)" — a DIFFERENT item's
|
|
15
|
+
* closure. A substring of an adjacent concept.
|
|
16
|
+
* · A row stating that a risk "cannot be closed by repository work" was
|
|
17
|
+
* counted closed by saying so.
|
|
18
|
+
*
|
|
19
|
+
* The predicate is duplicated here rather than imported because the generator is
|
|
20
|
+
* a standalone ESM script with no exports; the duplication is pinned by
|
|
21
|
+
* `matches the generator's source` below, which fails if the two drift.
|
|
22
|
+
*
|
|
23
|
+
* @see scripts/generate-assurance-status.mjs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
28
|
+
import { join, dirname } from 'node:path';
|
|
29
|
+
import { V1_DIR } from './paths.js';
|
|
30
|
+
|
|
31
|
+
const NEGATED = /\b(cannot|can ?not|could not|will not|never|not)\s+be\s+(closed|resolved)\b|\bnot closed\b/i;
|
|
32
|
+
const EXPLICIT = /\*\*(CLOSED|Closed)\b|~~|Realised and remediated/i;
|
|
33
|
+
const TRANSFERRED = /\*\*(?:OPEN\s+—\s+)?TRANSFERRED\b/i;
|
|
34
|
+
|
|
35
|
+
function disposition(status: string): 'closed' | 'transferred' | 'open' {
|
|
36
|
+
const closed = EXPLICIT.test(status) && !NEGATED.test(status);
|
|
37
|
+
if (closed) return 'closed';
|
|
38
|
+
return TRANSFERRED.test(status) ? 'transferred' : 'open';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('assurance risk disposition', () => {
|
|
42
|
+
it('does NOT read a nested reference to another item as this row being closed', () => {
|
|
43
|
+
// The exact RFC 0151 R1 shape. "Open", "unwitnessed", and a parenthetical
|
|
44
|
+
// about gap G1 closing — a different thing entirely.
|
|
45
|
+
const status =
|
|
46
|
+
'Open — **Sweep 2026-08-16:** **Mitigated in prose** — inverse-action identity tuple stated; ' +
|
|
47
|
+
'`compensation.md` §C now states the persistence shape (G1 closed 2026-08-16); unwitnessed for retry-stability';
|
|
48
|
+
expect(disposition(status)).toBe('open');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does NOT read a row that says a risk cannot be closed as closed', () => {
|
|
52
|
+
expect(disposition('**OPEN — TRANSFERRED.** This risk cannot be closed by repository work.')).not.toBe('closed');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reads an explicit marker as closed', () => {
|
|
56
|
+
expect(disposition('**CLOSED.** The recurrence mechanism is in the tree and executing.')).toBe('closed');
|
|
57
|
+
expect(disposition('~~superseded~~ — folded into RFC 0150 §D')).toBe('closed');
|
|
58
|
+
expect(disposition('**Realised and remediated in scope:** bundle 1 invalidated')).toBe('closed');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('distinguishes transferred from open, because §A.1 turns on the difference', () => {
|
|
62
|
+
expect(disposition('**OPEN — TRANSFERRED to a named tracked surface.** Tracked in KNOWN-LIMITS.')).toBe('transferred');
|
|
63
|
+
expect(disposition('Open — unwitnessed. No host advertises the capability.')).toBe('open');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('is not fooled by the word appearing in ordinary prose', () => {
|
|
67
|
+
expect(disposition('Open — the comment window closed without review.')).toBe('open');
|
|
68
|
+
expect(disposition('Open — closes when a host implements fencing.')).toBe('open');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('matches the generator source — fails if the two drift apart', () => {
|
|
72
|
+
// `V1_DIR` is null in the PUBLISHED package layout, which ships no `spec/`
|
|
73
|
+
// and no `scripts/`. Resolving the path at module scope — or casting the
|
|
74
|
+
// null away — is what made six scenarios throw at import for every npm
|
|
75
|
+
// consumer while staying green in a repo checkout. Resolve inside the test
|
|
76
|
+
// and skip when the repo is not there.
|
|
77
|
+
if (V1_DIR === null) return;
|
|
78
|
+
const script = join(dirname(V1_DIR), '..', 'scripts', 'generate-assurance-status.mjs');
|
|
79
|
+
if (!existsSync(script)) return;
|
|
80
|
+
const src = readFileSync(script, 'utf8');
|
|
81
|
+
// Guard the shape, not the byte-for-byte text: the generator must still gate
|
|
82
|
+
// on an explicit marker AND a negation check, never on a bare substring.
|
|
83
|
+
expect(src, 'generator must keep the negation guard').toMatch(/const negated = /);
|
|
84
|
+
expect(src, 'generator must require an explicit closed marker').toMatch(/const explicitlyClosed =/);
|
|
85
|
+
expect(src, 'generator must still separate transferred rows').toMatch(/const transferred = /);
|
|
86
|
+
expect(
|
|
87
|
+
/const closed = \/\(\^\|\\s\)\(closed\|resolved/.test(src),
|
|
88
|
+
'the bare-substring test must not come back',
|
|
89
|
+
).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -49,6 +49,15 @@ export function requirementIdForFile(basename: string): string {
|
|
|
49
49
|
: `openwop.scenario.${basename.replace(/\.test\.ts$/, '')}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Marks an `executed-pass` row whose file ALSO recorded a soft-skip note — the
|
|
54
|
+
* file asserted something, then stopped short (gap G8). Greppable on purpose: a
|
|
55
|
+
* bundle reader filters `disposition === 'executed-pass' && detail?.startsWith(
|
|
56
|
+
* PARTIAL_WITNESS_PREFIX)` to find rows where the requirement may not have been
|
|
57
|
+
* the thing that passed.
|
|
58
|
+
*/
|
|
59
|
+
export const PARTIAL_WITNESS_PREFIX = 'partial-witness: ';
|
|
60
|
+
|
|
52
61
|
export type FileTestState = 'pass' | 'fail' | 'skip';
|
|
53
62
|
|
|
54
63
|
/** Worker half: fold a file's per-test states (+ any gate-recorded reason) into
|
|
@@ -125,6 +134,27 @@ export function resolveFileRecord(
|
|
|
125
134
|
// dead code — which is how seven files carried notes the ledger never saw.
|
|
126
135
|
disposition = noted.kind;
|
|
127
136
|
detail = noted.reason;
|
|
137
|
+
} else if (noted !== null && disposition === 'executed-pass') {
|
|
138
|
+
// PARTIAL WITNESS (2026-08-19, gap G8). The file asserted something and then
|
|
139
|
+
// soft-skipped: `return softSkip(...)` yields a PASS state, not a skip, so
|
|
140
|
+
// neither branch above fires and the note used to be discarded outright. The
|
|
141
|
+
// row then read `executed-pass` for a requirement the run may never have
|
|
142
|
+
// reached — e.g. a file asserting a `201` setup precondition before
|
|
143
|
+
// returning `inapplicable`.
|
|
144
|
+
//
|
|
145
|
+
// Same defect as the note-after-`ctx.skip()` case the comment above records;
|
|
146
|
+
// note-after-ASSERTION was the half that stayed. Both hid because nothing
|
|
147
|
+
// goes red.
|
|
148
|
+
//
|
|
149
|
+
// The disposition is deliberately NOT changed. Honouring the note here would
|
|
150
|
+
// downgrade a file that legitimately completed its requirement AND
|
|
151
|
+
// soft-skipped an optional extra leg — trading a false positive for a false
|
|
152
|
+
// negative, on a per-FILE note that cannot say which leg it came from. The
|
|
153
|
+
// durable fix is per-`it` recording; this makes the affected rows
|
|
154
|
+
// self-identifying first, so that change follows measurement instead of
|
|
155
|
+
// preceding it. `detail` is permitted on `executed-pass` (RFC 0148 §A only
|
|
156
|
+
// REQUIRES it for other dispositions), so this is additive on the wire.
|
|
157
|
+
detail = `${PARTIAL_WITNESS_PREFIX}${noted.kind}: ${noted.reason}`;
|
|
128
158
|
}
|
|
129
159
|
return detail === undefined ? { disposition } : { disposition, detail };
|
|
130
160
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `webhook-receiver.ts` — the signature-header contract.
|
|
3
|
+
*
|
|
4
|
+
* This verifier is the reference a subscriber implementer copies. It required
|
|
5
|
+
* `v1=` and therefore rejected, as malformed, the exact header `webhooks.md`
|
|
6
|
+
* §"Delivery headers" mandates (`X-openwop-Signature: sha256={hex}`). The
|
|
7
|
+
* divergence survived because `webhook-receiver-adversarial.test.ts` signs with
|
|
8
|
+
* `signPayload` and verifies with `verifyWebhookDelivery` — a closed loop that is
|
|
9
|
+
* self-consistent and wrong, and so green against every host. These cases pin the
|
|
10
|
+
* header against the SPEC rather than against the suite's own output.
|
|
11
|
+
*
|
|
12
|
+
* @see webhook-receiver.ts, spec/v1/webhooks.md §"Delivery headers"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { createHmac } from 'node:crypto';
|
|
17
|
+
import {
|
|
18
|
+
SIGNATURE_PREFIX,
|
|
19
|
+
createReceiverState,
|
|
20
|
+
verifyWebhookDelivery,
|
|
21
|
+
signPayload,
|
|
22
|
+
} from './webhook-receiver.js';
|
|
23
|
+
|
|
24
|
+
const SECRET = 'shhh-not-a-real-secret';
|
|
25
|
+
const BODY = JSON.stringify({ event: { type: 'run.completed' } });
|
|
26
|
+
|
|
27
|
+
/** Build the header exactly as `webhooks.md` documents it, not as we emit it. */
|
|
28
|
+
function specShapedHeader(ts: number): string {
|
|
29
|
+
const hex = createHmac('sha256', SECRET).update(`${ts}.${BODY}`).digest('hex');
|
|
30
|
+
return `sha256=${hex}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('webhook-receiver: the X-openwop-Signature prefix follows the spec', () => {
|
|
34
|
+
it('the constant is the spec value, not the algorithm-header value', () => {
|
|
35
|
+
// `v1` names the SIGNING SCHEME (X-openwop-Signature-Algorithm). It is not
|
|
36
|
+
// the encoding prefix. One value, two fields — the conflation this fixes.
|
|
37
|
+
expect(SIGNATURE_PREFIX).toBe('sha256=');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('ACCEPTS a header built to the spec by hand, with no help from signPayload', () => {
|
|
41
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
42
|
+
const result = verifyWebhookDelivery(
|
|
43
|
+
SECRET,
|
|
44
|
+
specShapedHeader(ts),
|
|
45
|
+
'v1',
|
|
46
|
+
String(ts),
|
|
47
|
+
BODY,
|
|
48
|
+
createReceiverState(),
|
|
49
|
+
);
|
|
50
|
+
expect(result.accepted).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('REJECTS the pre-2026-08-19 `v1=` prefix as malformed — the shape the spec never defined', () => {
|
|
54
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
55
|
+
const hex = createHmac('sha256', SECRET).update(`${ts}.${BODY}`).digest('hex');
|
|
56
|
+
const result = verifyWebhookDelivery(
|
|
57
|
+
SECRET,
|
|
58
|
+
`v1=${hex}`,
|
|
59
|
+
'v1',
|
|
60
|
+
String(ts),
|
|
61
|
+
BODY,
|
|
62
|
+
createReceiverState(),
|
|
63
|
+
);
|
|
64
|
+
expect(result.accepted).toBe(false);
|
|
65
|
+
if (!result.accepted) expect(result.reason).toBe('malformed_signature_header');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('signPayload emits what the verifier accepts AND what the spec documents', () => {
|
|
69
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
70
|
+
const { signatureHeader, algorithmHeader } = signPayload(SECRET, ts, BODY);
|
|
71
|
+
// Both halves matter: agreeing with the verifier alone is the closed loop
|
|
72
|
+
// that hid the bug, so this also compares against the hand-built header.
|
|
73
|
+
expect(signatureHeader).toBe(specShapedHeader(ts));
|
|
74
|
+
expect(algorithmHeader).toBe('v1');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
|
|
18
18
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The `X-openwop-Signature` value prefix, per `webhooks.md` §"Delivery headers"
|
|
22
|
+
* (`sha256={hex}`). Distinct from the ALGORITHM header's `v1`, which names the
|
|
23
|
+
* signing scheme, not the encoding — see `verifyWebhookDelivery`.
|
|
24
|
+
*/
|
|
25
|
+
export const SIGNATURE_PREFIX = 'sha256=';
|
|
26
|
+
|
|
20
27
|
export const DEFAULT_FRESHNESS_WINDOW_SECONDS = 300;
|
|
21
28
|
|
|
22
29
|
export type WebhookRejectionReason =
|
|
@@ -75,10 +82,24 @@ export function verifyWebhookDelivery(
|
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
// 2. Signature header parse.
|
|
78
|
-
|
|
85
|
+
//
|
|
86
|
+
// `sha256=`, NOT `v1=` (corrected 2026-08-19). `webhooks.md` §"Delivery
|
|
87
|
+
// headers" specifies `X-openwop-Signature: sha256={hex}` and its verification
|
|
88
|
+
// recipe says "Strip the `sha256=` prefix". This verifier required `v1=` and
|
|
89
|
+
// rejected the spec's own header as malformed — so the reference verifier a
|
|
90
|
+
// subscriber implementer would copy refused every conforming delivery.
|
|
91
|
+
//
|
|
92
|
+
// The confusion is visible one comment above: `v1` is the value of the
|
|
93
|
+
// ALGORITHM header (`X-openwop-Signature-Algorithm: v1`), a different field.
|
|
94
|
+
// One value, two fields, conflated. It survived because
|
|
95
|
+
// `webhook-receiver-adversarial.test.ts` signs with `signPayload` and verifies
|
|
96
|
+
// with this function — a closed loop that is self-consistent and wrong, and
|
|
97
|
+
// therefore green on every host. Reported by a tier-2 host that could not
|
|
98
|
+
// adjudicate which of the suite's three signature shapes was canonical.
|
|
99
|
+
if (!signatureHeader.startsWith(SIGNATURE_PREFIX)) {
|
|
79
100
|
return { accepted: false, reason: 'malformed_signature_header' };
|
|
80
101
|
}
|
|
81
|
-
const providedHex = signatureHeader.slice(
|
|
102
|
+
const providedHex = signatureHeader.slice(SIGNATURE_PREFIX.length);
|
|
82
103
|
if (!/^[0-9a-f]+$/i.test(providedHex)) {
|
|
83
104
|
return { accepted: false, reason: 'malformed_signature_header' };
|
|
84
105
|
}
|
|
@@ -130,7 +151,7 @@ export function signPayload(
|
|
|
130
151
|
const bodyStr = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
|
|
131
152
|
const hex = createHmac('sha256', secret).update(`${timestamp}.${bodyStr}`, 'utf8').digest('hex');
|
|
132
153
|
return {
|
|
133
|
-
signatureHeader:
|
|
154
|
+
signatureHeader: `${SIGNATURE_PREFIX}${hex}`,
|
|
134
155
|
timestampHeader: String(timestamp),
|
|
135
156
|
algorithmHeader: 'v1',
|
|
136
157
|
};
|
|
@@ -47,6 +47,7 @@ import { pollUntilTerminal, pollUntilStatus } from '../lib/polling.js';
|
|
|
47
47
|
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
48
48
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
49
49
|
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
50
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* Callback-shaped: the host issues A2A JSON-RPC calls to the suite's fake peer.
|
|
@@ -403,21 +404,76 @@ describe.skipIf(HTTP_SKIP)('a2a-task-roundtrip: durable tasks/get after disconne
|
|
|
403
404
|
});
|
|
404
405
|
});
|
|
405
406
|
|
|
406
|
-
|
|
407
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Push-config SSRF, TWO-SIDED (RFC 0100 §4, a2a-integration.md §D.6).
|
|
409
|
+
*
|
|
410
|
+
* The RFC 0093 webhook-egress guard has two arms — `webhooks.md`
|
|
411
|
+
* §"SSRF protection" rejects non-`https://` protocols AND private/loopback/
|
|
412
|
+
* link-local/ULA/metadata addresses — and a host may implement either one
|
|
413
|
+
* alone.
|
|
414
|
+
*
|
|
415
|
+
* Until 2026-08-25 this file probed with a single `http://10.0.0.5/push`,
|
|
416
|
+
* which **violates both arms at once**. Either arm alone refuses it, so a
|
|
417
|
+
* `>= 400` witnessed *that something refused* and never *which guard ran*.
|
|
418
|
+
* A host with only the address arm passed; so did a host with only the
|
|
419
|
+
* scheme arm; so would a host that refused every push URL for an unrelated
|
|
420
|
+
* reason. The assertion was real and the conclusion drawn from it was not.
|
|
421
|
+
*
|
|
422
|
+
* The probes below isolate one arm each: `https` at a private address can
|
|
423
|
+
* only be refused by the address arm, and `http` at a public host can only
|
|
424
|
+
* be refused by the scheme arm. Two legs is the minimum that distinguishes
|
|
425
|
+
* them — the same reason a negative control is not optional.
|
|
426
|
+
*
|
|
427
|
+
* The scheme leg is new, and the obligation it checks was previously stated
|
|
428
|
+
* only by reference (every prior wording abbreviated the guard to its
|
|
429
|
+
* address arm). `COMPATIBILITY.md` §3 records the Class 3 classification: a
|
|
430
|
+
* host accepting a plaintext push target was never conforming. An
|
|
431
|
+
* implementer reddened by this leg is reading a clarification, not a new
|
|
432
|
+
* requirement — the failure message says so.
|
|
433
|
+
*/
|
|
434
|
+
describe.skipIf(HTTP_SKIP)('a2a-task-roundtrip: push-config SSRF, two-sided (gated on a2a.pushNotifications; RFC 0100)', () => {
|
|
435
|
+
async function registerPush(url: string): Promise<{ status: number } | null> {
|
|
408
436
|
const a2a = await readCapabilityFamily<{ pushNotifications?: boolean }>('a2a');
|
|
409
|
-
if (!behaviorGate('a2a.pushNotifications', a2a?.pushNotifications === true)) return;
|
|
437
|
+
if (!behaviorGate('a2a.pushNotifications', a2a?.pushNotifications === true)) return null;
|
|
438
|
+
const res = await driver.post('/v1/host/sample/a2a/tasks/push-config', { taskId: 'run_x', url });
|
|
439
|
+
if (res.status === 404 || res.status === 403) {
|
|
440
|
+
// Previously a bare `return`, invisible to the RFC 0148 §A ledger because
|
|
441
|
+
// this file's other tests assert — so the file recorded `executed-pass`
|
|
442
|
+
// while this leg had witnessed nothing. Say why instead.
|
|
443
|
+
return seamAbsent(
|
|
444
|
+
'a2a-push-egress-ssrf — the `/v1/host/sample/a2a/tasks/push-config` seam is not mounted (404/403), so neither guard arm is observable',
|
|
445
|
+
) ?? null;
|
|
446
|
+
}
|
|
447
|
+
return { status: res.status };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
it('ADDRESS arm: an https pushConfig.url at a private address is refused', async () => {
|
|
451
|
+
// `https` on purpose: this probe is refusable ONLY by the address arm, so a
|
|
452
|
+
// host that implements the scheme arm alone cannot pass it by accident.
|
|
453
|
+
const res = await registerPush('https://10.0.0.5/push');
|
|
454
|
+
if (res === null) return;
|
|
455
|
+
expect(
|
|
456
|
+
res.status >= 400,
|
|
457
|
+
driver.describe(
|
|
458
|
+
'a2a-integration.md §D.6 (address arm)',
|
|
459
|
+
'a2a-push-egress-ssrf — a pushConfig.url at a private/loopback address MUST be refused before any push, even over https',
|
|
460
|
+
),
|
|
461
|
+
).toBe(true);
|
|
462
|
+
});
|
|
410
463
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
464
|
+
it('SCHEME arm: an http pushConfig.url at a public host is refused', async () => {
|
|
465
|
+
// Public hostname on purpose: refusable ONLY by the scheme arm.
|
|
466
|
+
const res = await registerPush('http://push.example.com/push');
|
|
467
|
+
if (res === null) return;
|
|
416
468
|
expect(
|
|
417
469
|
res.status >= 400,
|
|
418
470
|
driver.describe(
|
|
419
|
-
'a2a-integration.md §
|
|
420
|
-
'a2a-push-egress-ssrf — a
|
|
471
|
+
'a2a-integration.md §D.6 (scheme arm)',
|
|
472
|
+
'a2a-push-egress-ssrf — a plaintext `http://` pushConfig.url MUST be refused before any push. '
|
|
473
|
+
+ 'The RFC 0093 webhook-egress guard is the `webhooks.md` §"SSRF protection" list IN FULL, whose first entry is '
|
|
474
|
+
+ '"Non-`https://` protocols". Every prior wording of this requirement abbreviated the guard to its address arm; '
|
|
475
|
+
+ 'COMPATIBILITY.md §3 records this as a Class 3 clarification, so a host failing here was never conforming rather '
|
|
476
|
+
+ 'than newly non-conforming.',
|
|
421
477
|
),
|
|
422
478
|
).toBe(true);
|
|
423
479
|
});
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
import { describe, it, expect } from 'vitest';
|
|
40
40
|
import { driver } from '../lib/driver.js';
|
|
41
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
41
42
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
42
43
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
43
44
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
@@ -149,8 +150,7 @@ describe.skipIf(HTTP_SKIP)('agent-channel-dispatch (RFC 0082 §B): production ru
|
|
|
149
150
|
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
150
151
|
{ fromSeq: 0, mode: 'replay' },
|
|
151
152
|
);
|
|
152
|
-
if (fork1.status
|
|
153
|
-
// replay advertised but not implemented for this run — skip-equivalent.
|
|
153
|
+
if (forkDeclined(fork1.status, 'channel-dispatch replay fork 1')) {
|
|
154
154
|
ctx.skip();
|
|
155
155
|
return;
|
|
156
156
|
}
|
|
@@ -213,7 +213,7 @@ describe.skipIf(HTTP_SKIP)('agent-channel-dispatch (RFC 0082 §B): production ru
|
|
|
213
213
|
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
214
214
|
{ fromSeq: 0, mode: 'replay' },
|
|
215
215
|
);
|
|
216
|
-
if (fork2.status
|
|
216
|
+
if (forkDeclined(fork2.status, 'channel-dispatch replay fork 2')) {
|
|
217
217
|
ctx.skip();
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
@@ -51,6 +51,35 @@ describe('RFC 0148 §A — conformance-execution-witness: the runner record', ()
|
|
|
51
51
|
expect(resolveFileRecord(['pass'], undefined, 0, { kind: 'skipped', reason: 'operator opt-out' })).toEqual({ disposition: 'skipped', detail: 'operator opt-out' });
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it('gap G8 — a file that asserted then soft-skipped keeps the note as a partial-witness detail, and its disposition does NOT move', () => {
|
|
55
|
+
// `return softSkip(...)` produces a PASS state, not a skip, so neither the
|
|
56
|
+
// zero-assertion branch nor the all-skipped branch fires. The note used to be
|
|
57
|
+
// discarded outright and the row read a bare `executed-pass` — for a
|
|
58
|
+
// requirement the run may never have reached, because the assertions came
|
|
59
|
+
// from a positive control or a setup precondition. Measured on this exact
|
|
60
|
+
// function by a tier-1 host writing a scenario whose first leg is a control.
|
|
61
|
+
const one = resolveFileRecord(['pass'], undefined, 1, { kind: 'blocked', reason: 'fork returned 501' });
|
|
62
|
+
expect(one.disposition).toBe('executed-pass');
|
|
63
|
+
expect(one.detail).toBe('partial-witness: blocked: fork returned 501');
|
|
64
|
+
|
|
65
|
+
const many = resolveFileRecord(['pass', 'pass'], undefined, 4, { kind: 'inapplicable', reason: 'fork unsupported' });
|
|
66
|
+
expect(many.disposition).toBe('executed-pass');
|
|
67
|
+
expect(many.detail).toBe('partial-witness: inapplicable: fork unsupported');
|
|
68
|
+
|
|
69
|
+
// The disposition deliberately does not move: honouring a per-FILE note here
|
|
70
|
+
// would downgrade a file that legitimately finished its requirement and also
|
|
71
|
+
// soft-skipped an optional extra leg. The marker makes the rows findable so
|
|
72
|
+
// the per-`it` fix can follow measurement instead of preceding it.
|
|
73
|
+
expect(many.disposition).not.toBe('inapplicable');
|
|
74
|
+
|
|
75
|
+
// A clean pass is untouched — no note, no marker, no detail at all.
|
|
76
|
+
expect(resolveFileRecord(['pass', 'pass'], undefined, 7, null)).toEqual({ disposition: 'executed-pass' });
|
|
77
|
+
|
|
78
|
+
// A FAILING file keeps its failure; the note must not dress a red as a pass.
|
|
79
|
+
const red = resolveFileRecord(['pass', 'fail'], undefined, 3, { kind: 'blocked', reason: 'seam 404' });
|
|
80
|
+
expect(red.disposition).toBe('executed-fail');
|
|
81
|
+
});
|
|
82
|
+
|
|
54
83
|
it('every test ctx.skip()ped takes the noted reason when one was written before the skip, else stays the blocked marker', () => {
|
|
55
84
|
// `ctx.skip()` throws — a `softSkip(...)` AFTER it is dead code. Seven files
|
|
56
85
|
// carried exactly that dead note and reported as unclassified for a suite minor.
|
|
@@ -51,17 +51,21 @@ describe('cross-host-traceparent-propagation: behavioral (RFC 0040 §B)', () =>
|
|
|
51
51
|
// Until the peer harness lands, the assertion is surfaced as `it.skip` so
|
|
52
52
|
// test reporters track the gap rather than reporting a vacuous PASS.
|
|
53
53
|
// Marked out of stable profile via RFC 0042 §B (experimental tier):
|
|
54
|
-
// RFC 0040
|
|
55
|
-
//
|
|
54
|
+
// RFC 0040 is Accepted, but the cross-host behavioral scenario stays
|
|
55
|
+
// experimental until a non-steward host produces the behavioral
|
|
56
|
+
// traceparent evidence — RFC status (Accepted) and conformance-profile
|
|
57
|
+
// tier (experimental) are separate axes per RFC 0042 §B. Hosts that wire
|
|
58
|
+
// Phase 3 cross-host causation SHOULD advertise
|
|
56
59
|
// `multiAgent.executionModel.tier: 'experimental'` per RFC 0042 §A
|
|
57
|
-
// until
|
|
60
|
+
// until that behavioral evidence lands. Path-to-runnable
|
|
58
61
|
// requires the MCP peer harness (OPENWOP_MCP_REAL_SERVER_URL) +
|
|
59
62
|
// inbound-header recorder; flips to a real `it()` on first non-steward
|
|
60
63
|
// Phase 3 host advertising matching capabilities.
|
|
61
64
|
it.skip('Phase 3 host MUST inject parent run\'s traceparent into outbound MCP requests — out of stable profile via RFC 0042');
|
|
62
65
|
|
|
63
|
-
// Same routing — out of stable profile via RFC 0042 §B until
|
|
64
|
-
//
|
|
65
|
-
// designed alongside the
|
|
66
|
+
// Same routing — out of stable profile via RFC 0042 §B until behavioral
|
|
67
|
+
// A2A cross-host evidence lands (RFC 0040 itself is already Accepted); the
|
|
68
|
+
// A2A test seam contract is still to be designed alongside the
|
|
69
|
+
// corresponding peer harness.
|
|
66
70
|
it.skip('Phase 3 host MUST inject parent run\'s traceparent into outbound A2A messages — out of stable profile via RFC 0042');
|
|
67
71
|
});
|