@openwop/openwop-conformance 2.36.1 → 2.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +4 -3
- package/coverage.md +1 -0
- package/dist/lib/requirement-ledger.js +44 -3
- package/dist/lib/scenario-disposition.js +37 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +510 -73
- package/scenario-majors.json +14 -2
- package/schemas/CORPUS-STAMP.json +27 -27
- package/src/lib/a2a-error-info.ts +44 -0
- package/src/lib/a2a-fake-peer.ts +50 -7
- package/src/lib/effect-receiver.ts +135 -0
- package/src/lib/front-mux.ts +102 -0
- package/src/lib/mcp-fake-server.ts +15 -4
- package/src/lib/oidc-issuer.ts +15 -5
- package/src/lib/requirement-ledger.ts +82 -4
- package/src/lib/scenario-disposition.ts +41 -3
- package/src/lib/scoped-receiver.ts +223 -0
- package/src/lib/triggerBridge.ts +49 -0
- package/src/scenarios/a2a-1-0-agent-card.test.ts +19 -7
- package/src/scenarios/auth-subject-link.test.ts +18 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
- package/src/scenarios/v2-a2a-client-error-details.test.ts +70 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +84 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
- package/src/scenarios/v2-durability-recovery.test.ts +83 -35
- package/src/scenarios/v2-idempotency-in-flight.test.ts +132 -0
- package/src/scenarios/v2-interrupt-resolve-terminal.test.ts +107 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +41 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +13 -0
- package/src/scenarios/v2-sse-last-event-id-cursor.test.ts +133 -0
- package/src/scenarios/v2-terminal-event-once.test.ts +15 -21
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
- package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
- package/src/setup.ts +24 -4
|
@@ -34,8 +34,9 @@
|
|
|
34
34
|
* @see SECURITY/threat-model-prompt-injection.md §"UNTRUSTED marker"
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
import {
|
|
38
|
-
import { createServer, type Server } from 'node:http';
|
|
37
|
+
import { randomBytes } from 'node:crypto';
|
|
38
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
39
|
+
import { frontedEndpoint, registerBehindFront, routeFronted, unregisterBehindFront } from './front-mux.js';
|
|
39
40
|
import type { AddressInfo } from 'node:net';
|
|
40
41
|
|
|
41
42
|
export interface McpInvocation {
|
|
@@ -140,6 +141,8 @@ export const TOOLS_PAGE_SIZE = 3;
|
|
|
140
141
|
export class McpFakeServer {
|
|
141
142
|
private _server: Server | null = null;
|
|
142
143
|
private _boundPort = 0;
|
|
144
|
+
/** This fake's path segment behind a shared public front (`lib/front-mux.ts`). */
|
|
145
|
+
private readonly _nonce = randomBytes(9).toString('hex');
|
|
143
146
|
private readonly _invocations: McpInvocation[] = [];
|
|
144
147
|
private readonly _revisions: readonly McpRevision[];
|
|
145
148
|
private _stateCounter = 0;
|
|
@@ -170,12 +173,19 @@ export class McpFakeServer {
|
|
|
170
173
|
|
|
171
174
|
async start(port: number = 0): Promise<void> {
|
|
172
175
|
return new Promise((resolve, reject) => {
|
|
173
|
-
const
|
|
176
|
+
const dispatch = (req: IncomingMessage, res: ServerResponse): void => { void this._handle(req, res); };
|
|
177
|
+
// Whichever fake owns the pinned port also carries every other fake's
|
|
178
|
+
// nonce-pathed traffic (`lib/front-mux.ts`) — ask that first.
|
|
179
|
+
const server = createServer((req, res) => {
|
|
180
|
+
if (routeFronted('OPENWOP_MCP_FAKE_SERVER_URL', req, res)) return;
|
|
181
|
+
dispatch(req, res);
|
|
182
|
+
});
|
|
174
183
|
server.on('error', reject);
|
|
175
184
|
server.listen(port, '127.0.0.1', () => {
|
|
176
185
|
const addr = server.address() as AddressInfo;
|
|
177
186
|
this._server = server;
|
|
178
187
|
this._boundPort = addr.port;
|
|
188
|
+
registerBehindFront('OPENWOP_MCP_FAKE_SERVER_URL', this._nonce, dispatch);
|
|
179
189
|
resolve();
|
|
180
190
|
});
|
|
181
191
|
});
|
|
@@ -185,6 +195,7 @@ export class McpFakeServer {
|
|
|
185
195
|
if (!this._server) return;
|
|
186
196
|
const server = this._server;
|
|
187
197
|
this._server = null;
|
|
198
|
+
unregisterBehindFront('OPENWOP_MCP_FAKE_SERVER_URL', this._nonce);
|
|
188
199
|
return new Promise((resolve, reject) => {
|
|
189
200
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
190
201
|
});
|
|
@@ -203,7 +214,7 @@ export class McpFakeServer {
|
|
|
203
214
|
* fixed port to forward to.
|
|
204
215
|
*/
|
|
205
216
|
hostFacingEndpoint(): string {
|
|
206
|
-
return
|
|
217
|
+
return frontedEndpoint('OPENWOP_MCP_FAKE_SERVER_URL', 'OPENWOP_MCP_FAKE_SERVER_PORT', this.endpoint(), this._boundPort, this._nonce);
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
invocations(): readonly McpInvocation[] {
|
package/src/lib/oidc-issuer.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import {
|
|
20
20
|
createSign,
|
|
21
21
|
generateKeyPairSync,
|
|
22
|
+
randomBytes,
|
|
22
23
|
type KeyObject,
|
|
23
24
|
} from 'node:crypto';
|
|
24
25
|
|
|
@@ -166,10 +167,22 @@ export function createSyntheticOIDCIssuer(
|
|
|
166
167
|
throw new Error('[oidc-issuer] issuer and audience are required');
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
// The kid is unique PER INSTANCE, not just per rotation (2.37.0). Every
|
|
171
|
+
// instance generates a fresh key, and two scenarios can publish at the same
|
|
172
|
+
// OPENWOP_TEST_OIDC_ISSUER_URL (v2-oidc-id-token-audience and
|
|
173
|
+
// v2-lane-exp-only-bound both do). With a per-instance counter alone every
|
|
174
|
+
// instance minted `openwop-conformance-key-1`, so a host that caches JWKS by
|
|
175
|
+
// kid — RFC 7517 §4.5 says a kid identifies ONE key — verified the second
|
|
176
|
+
// scenario's tokens against the first scenario's key and rejected its VALID
|
|
177
|
+
// control as `invalid_signature`. Measured on openwop-app (suite 2.36.1):
|
|
178
|
+
// 4/4 red in a full run, 4/4 green filtered. A new kid over a new key lets a
|
|
179
|
+
// conformant host see an unknown kid and re-fetch, as it must.
|
|
180
|
+
const instance = randomBytes(6).toString('hex');
|
|
181
|
+
const kidFor = (n: number): string => `openwop-conformance-key-${instance}-${n}`;
|
|
169
182
|
let rotationCounter = 1;
|
|
170
183
|
let material = generateKeyMaterial(
|
|
171
184
|
algorithm,
|
|
172
|
-
opts.keyId ??
|
|
185
|
+
opts.keyId ?? kidFor(rotationCounter),
|
|
173
186
|
);
|
|
174
187
|
|
|
175
188
|
return {
|
|
@@ -232,10 +245,7 @@ export function createSyntheticOIDCIssuer(
|
|
|
232
245
|
|
|
233
246
|
rotateKey(): void {
|
|
234
247
|
rotationCounter += 1;
|
|
235
|
-
material = generateKeyMaterial(
|
|
236
|
-
algorithm,
|
|
237
|
-
`openwop-conformance-key-${rotationCounter}`,
|
|
238
|
-
);
|
|
248
|
+
material = generateKeyMaterial(algorithm, kidFor(rotationCounter));
|
|
239
249
|
},
|
|
240
250
|
};
|
|
241
251
|
}
|
|
@@ -87,15 +87,68 @@ const journal: LedgerEntry[] = [];
|
|
|
87
87
|
* dispositions throws: RFC 0148 §A says **exactly one** disposition per
|
|
88
88
|
* requirement, and a silent last-write-wins would let a later soft-skip
|
|
89
89
|
* overwrite an earlier real failure — the failure mode in reverse.
|
|
90
|
+
*
|
|
91
|
+
* `extras.fold` is the one exception, for the per-`it` rows `setup.ts` records
|
|
92
|
+
* when several `it` legs witness ONE requirement id; see its docblock below.
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* How certifiable each disposition is, least first. `readLedgerFile` has always
|
|
96
|
+
* resolved a cross-worker disagreement this way — "one worker said it failed"
|
|
97
|
+
* outranks "another said it passed", and an unresolvable disagreement must never
|
|
98
|
+
* round toward certification. `fold` below applies the SAME rule in-worker.
|
|
90
99
|
*/
|
|
100
|
+
const CERTIFIABILITY_RANK: Record<Disposition, number> = {
|
|
101
|
+
'executed-fail': 0,
|
|
102
|
+
blocked: 1,
|
|
103
|
+
'executed-pass': 2,
|
|
104
|
+
skipped: 3,
|
|
105
|
+
inapplicable: 4,
|
|
106
|
+
};
|
|
107
|
+
|
|
91
108
|
export function recordRequirement(
|
|
92
109
|
requirementId: string,
|
|
93
110
|
disposition: Disposition,
|
|
94
111
|
detail?: string,
|
|
95
|
-
extras?: {
|
|
112
|
+
extras?: {
|
|
113
|
+
assertionCount?: number;
|
|
114
|
+
scenarioFile?: string;
|
|
115
|
+
evidence?: RowEvidence;
|
|
116
|
+
/**
|
|
117
|
+
* FOLD instead of throw when this id already has a disposition (2.37.0).
|
|
118
|
+
*
|
|
119
|
+
* The strict contract above — one disposition per requirement per run,
|
|
120
|
+
* contradiction throws — is right for a scenario that classifies ITSELF:
|
|
121
|
+
* two hand-written verdicts for one id is an authoring bug, and the throw
|
|
122
|
+
* is how it surfaces.
|
|
123
|
+
*
|
|
124
|
+
* It is wrong for the per-`it` rows `setup.ts` records, because the corpus
|
|
125
|
+
* deliberately witnesses ONE requirement with SEVERAL `it` legs: 27
|
|
126
|
+
* scenario files hand `req()` a module-level `const ID`, so every leg in
|
|
127
|
+
* the file records under the same id. There the throw is not a guard —
|
|
128
|
+
* `setup.ts` wraps the call in `try {} catch {}` ("never fail a test for
|
|
129
|
+
* bookkeeping"), so the second verdict was silently DISCARDED, and, worse,
|
|
130
|
+
* discarded before it reached the JSONL sink.
|
|
131
|
+
*
|
|
132
|
+
* MEASURED, `v2-run-bulk-cancel.test.ts` on a tier-2 host: leg 1 passed
|
|
133
|
+
* (3 assertions), leg 2 failed on its 7th. The file row recorded
|
|
134
|
+
* `executed-fail` with 10 assertions and the detail "one or more assertions
|
|
135
|
+
* in the file failed"; `openwop.requirement.0170.run-bulk-cancel` recorded
|
|
136
|
+
* `executed-pass` with 3. The failing leg's message — which names the
|
|
137
|
+
* requirement AND prints the offending entry — existed, was computed by
|
|
138
|
+
* `resolveItRecord`, and was thrown away here. The operator had to
|
|
139
|
+
* hand-probe every assertion in the file against production to find out
|
|
140
|
+
* what had failed.
|
|
141
|
+
*
|
|
142
|
+
* Folding by `CERTIFIABILITY_RANK` makes the surviving row the least
|
|
143
|
+
* certifiable of the legs, carrying THAT leg's detail, with the legs'
|
|
144
|
+
* assertion counts summed — the same answer `readLedgerFile` would reach
|
|
145
|
+
* from the sink lines, so the in-memory ledger and the file agree.
|
|
146
|
+
*/
|
|
147
|
+
fold?: true;
|
|
148
|
+
},
|
|
96
149
|
): void {
|
|
97
150
|
const prior = ledger.get(requirementId);
|
|
98
|
-
if (prior !== undefined && prior.disposition !== disposition) {
|
|
151
|
+
if (prior !== undefined && prior.disposition !== disposition && extras?.fold !== true) {
|
|
99
152
|
throw new Error(
|
|
100
153
|
`RFC 0148 §A: ${requirementId} already recorded as '${prior.disposition}', now '${disposition}'. ` +
|
|
101
154
|
'Exactly one disposition per requirement per run.',
|
|
@@ -107,7 +160,7 @@ export function recordRequirement(
|
|
|
107
160
|
'Anything other than executed-pass MUST say why, or the ledger records an outcome nobody can act on.',
|
|
108
161
|
);
|
|
109
162
|
}
|
|
110
|
-
|
|
163
|
+
let entry: LedgerEntry = {
|
|
111
164
|
requirementId,
|
|
112
165
|
disposition,
|
|
113
166
|
...(detail === undefined ? {} : { detail }),
|
|
@@ -115,6 +168,27 @@ export function recordRequirement(
|
|
|
115
168
|
...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
|
|
116
169
|
...(extras?.evidence === undefined || disposition !== 'executed-pass' ? {} : { evidence: extras.evidence }),
|
|
117
170
|
};
|
|
171
|
+
if (prior !== undefined && extras?.fold === true) {
|
|
172
|
+
// The least-certifiable leg wins the disposition and keeps its own detail;
|
|
173
|
+
// a tie keeps whichever side actually said something. Counts sum, because
|
|
174
|
+
// both legs really did assert against the target for this one requirement.
|
|
175
|
+
const keepPrior = CERTIFIABILITY_RANK[prior.disposition] <= CERTIFIABILITY_RANK[disposition];
|
|
176
|
+
const winner = keepPrior ? prior : entry;
|
|
177
|
+
const loser = keepPrior ? entry : prior;
|
|
178
|
+
const count = (prior.assertionCount ?? 0) + (extras?.assertionCount ?? 0);
|
|
179
|
+
const keptDetail = winner.detail ?? loser.detail;
|
|
180
|
+
const hasCount = prior.assertionCount !== undefined || extras?.assertionCount !== undefined;
|
|
181
|
+
entry = {
|
|
182
|
+
requirementId,
|
|
183
|
+
disposition: winner.disposition,
|
|
184
|
+
...(keptDetail === undefined ? {} : { detail: keptDetail }),
|
|
185
|
+
...(hasCount ? { assertionCount: count } : {}),
|
|
186
|
+
...(winner.scenarioFile === undefined ? {} : { scenarioFile: winner.scenarioFile }),
|
|
187
|
+
// `evidence` is only meaningful on a pass; a fold that lands anywhere
|
|
188
|
+
// else drops it, exactly as the constructor above does.
|
|
189
|
+
...(winner.disposition === 'executed-pass' && winner.evidence !== undefined ? { evidence: winner.evidence } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
118
192
|
ledger.set(requirementId, entry);
|
|
119
193
|
journal.push(entry);
|
|
120
194
|
// File sink (RFC 0148 acceptance item 2, S6). The in-memory map lives in a
|
|
@@ -159,7 +233,11 @@ export function readLedgerFile(path: string): readonly LedgerEntry[] {
|
|
|
159
233
|
}
|
|
160
234
|
if (typeof e.requirementId !== 'string' || !DISPOSITIONS.includes(e.disposition)) continue;
|
|
161
235
|
const prior = merged.get(e.requirementId);
|
|
162
|
-
|
|
236
|
+
// `<=`, not `<`: a per-`it` FOLD (2.37.0) appends the cumulative row after
|
|
237
|
+
// the leg rows it folded, so on an equal disposition the LAST line is the
|
|
238
|
+
// one carrying the summed `assertionCount`. For genuinely duplicate lines
|
|
239
|
+
// the two are identical and the choice is a no-op.
|
|
240
|
+
if (prior === undefined || rank[e.disposition] <= rank[prior.disposition]) merged.set(e.requirementId, e);
|
|
163
241
|
}
|
|
164
242
|
return [...merged.values()].sort((a, b) => a.requirementId.localeCompare(b.requirementId));
|
|
165
243
|
}
|
|
@@ -118,8 +118,13 @@ export function resolveItRecord(
|
|
|
118
118
|
* sites were not measured and v1 bundles are read through its EOS.
|
|
119
119
|
*/
|
|
120
120
|
blockedStands = false,
|
|
121
|
+
/** The `it` title, so a failed row says WHICH leg of the requirement failed. */
|
|
122
|
+
testName?: string,
|
|
121
123
|
): { disposition: Disposition; detail?: string } {
|
|
122
|
-
if (state === 'fail')
|
|
124
|
+
if (state === 'fail') {
|
|
125
|
+
const where = testName === undefined || testName.trim() === '' ? '' : ` in "${testName.slice(0, 120)}"`;
|
|
126
|
+
return { disposition: 'executed-fail', detail: `the test executed and failed${where}: ${(firstError ?? 'no message').slice(0, 300)}` };
|
|
127
|
+
}
|
|
123
128
|
if (state === 'pass' && assertionCalls > 0) {
|
|
124
129
|
// `blockedDespiteAssertions` (soft-skip.ts): the leg says its setup
|
|
125
130
|
// assertions are not the requirement, and the requirement went unobserved.
|
|
@@ -142,14 +147,45 @@ export function resolveItRecord(
|
|
|
142
147
|
return { disposition: 'skipped', detail: 'vitest skipped the test (ctx.skip / it.skip) without a recorded gate reason' };
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
/** One failed `it` in a file: its title and its first error message. */
|
|
151
|
+
export interface TestFailure {
|
|
152
|
+
readonly name: string;
|
|
153
|
+
readonly message?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The `executed-fail` detail for a file row: WHICH cases failed, and what the
|
|
158
|
+
* first one said.
|
|
159
|
+
*
|
|
160
|
+
* Until 2.37.0 this was the fixed string "one or more assertions in the file
|
|
161
|
+
* failed". A tier-2 host read exactly that for `v2-run-bulk-cancel` — 10
|
|
162
|
+
* assertions, no case name, no message, and nothing else in the record for that
|
|
163
|
+
* file — and had to hand-probe every assertion in the file against production to
|
|
164
|
+
* find out what had happened. A bundle row whose only detail is that sentence is
|
|
165
|
+
* undiagnosable by construction, and every future flicker in any scenario had
|
|
166
|
+
* the same problem.
|
|
167
|
+
*/
|
|
168
|
+
export function failureDetail(failures: readonly TestFailure[], failedCount: number): string {
|
|
169
|
+
if (failures.length === 0) {
|
|
170
|
+
return `${failedCount} test(s) in the file failed; the runner captured no message`;
|
|
171
|
+
}
|
|
172
|
+
const head = failures[0]!;
|
|
173
|
+
const named = failures.slice(0, 3).map((f) => `"${f.name.slice(0, 120)}"`).join(', ');
|
|
174
|
+
const more = failures.length > 3 ? ` (+${failures.length - 3} more)` : '';
|
|
175
|
+
const msg = head.message === undefined || head.message.trim() === '' ? 'no message' : head.message.slice(0, 400);
|
|
176
|
+
return `${failures.length} test(s) failed — ${named}${more}; first failure: ${msg}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
/** Worker half: fold a file's per-test states (+ any gate-recorded reason) into
|
|
146
180
|
* the ONE disposition the file records. */
|
|
147
181
|
export function fileDisposition(
|
|
148
182
|
states: readonly FileTestState[],
|
|
149
183
|
gateReason: 'inapplicable' | 'skipped' | undefined,
|
|
150
184
|
assertionCount?: number,
|
|
185
|
+
failures: readonly TestFailure[] = [],
|
|
151
186
|
): { disposition: Disposition; detail?: string } {
|
|
152
|
-
|
|
187
|
+
const failed = states.filter((s) => s === 'fail').length;
|
|
188
|
+
if (failed > 0) return { disposition: 'executed-fail', detail: failureDetail(failures, failed) };
|
|
153
189
|
if (states.some((s) => s === 'pass')) {
|
|
154
190
|
// A test that early-returned through `behaviorGate` is reported by vitest
|
|
155
191
|
// as a pass with zero assertions. When EVERY passing test in the file did
|
|
@@ -195,6 +231,8 @@ export function resolveFileRecord(
|
|
|
195
231
|
assertionCount: number,
|
|
196
232
|
noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string } | null,
|
|
197
233
|
specCoherenceFile?: string,
|
|
234
|
+
/** The failed cases, so an `executed-fail` row NAMES them (2.37.0). */
|
|
235
|
+
failures: readonly TestFailure[] = [],
|
|
198
236
|
): { disposition: Disposition; detail?: string } {
|
|
199
237
|
// A scenario whose subject is the CORPUS, skipped because the published
|
|
200
238
|
// tarball does not bundle spec/v1/. RFC 0148 §A: `blocked` is defined over
|
|
@@ -211,7 +249,7 @@ export function resolveFileRecord(
|
|
|
211
249
|
) {
|
|
212
250
|
return { disposition: 'inapplicable', detail: SPEC_COHERENCE_DETAIL };
|
|
213
251
|
}
|
|
214
|
-
let { disposition, detail } = fileDisposition(states, gateReason, assertionCount);
|
|
252
|
+
let { disposition, detail } = fileDisposition(states, gateReason, assertionCount, failures);
|
|
215
253
|
if (disposition === 'executed-pass' && assertionCount === 0) {
|
|
216
254
|
if (noted !== null) {
|
|
217
255
|
disposition = noted.kind;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A webhook receiver that owns its destination — one exercise, one identity.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this file exists (2.37.0) ────────────────────────────────────────────
|
|
5
|
+
* Five surfaces in this suite bind `OPENWOP_WEBHOOK_RECEIVER_PORT`, and on a
|
|
6
|
+
* tunnelled cut four of them registered `resolveRegistrationUrl(...)`, which
|
|
7
|
+
* returns `OPENWOP_WEBHOOK_RECEIVER_URL` VERBATIM — one byte-identical string
|
|
8
|
+
* for every caller. `v2-bound-id-kinds`, `v2-webhook-delivery-shape`,
|
|
9
|
+
* `v2-webhook-durable-delivery` and `webhook-signed-delivery` therefore pointed
|
|
10
|
+
* four subscriptions at ONE destination.
|
|
11
|
+
*
|
|
12
|
+
* A webhook subscription is durable host-side state. It keeps delivering, and
|
|
13
|
+
* RETRYING, after the file that created it has finished, so the collision is not
|
|
14
|
+
* only between two live listeners — it is between an exercise and the leftovers
|
|
15
|
+
* of an earlier one. Whichever receiver held the pinned port read those
|
|
16
|
+
* leftovers as its own traffic, and `v2-webhook-durable-delivery` answers 500 BY
|
|
17
|
+
* DESIGN for the first attempts of every delivery key, so the exercise it landed
|
|
18
|
+
* on saw failures it never caused. `v2-webhook-delivery-shape`'s own
|
|
19
|
+
* `startReceiver` carried a comment describing the symptom from the other end:
|
|
20
|
+
* "the tunnel forwards to the PINNED port — held by the other receiver, which
|
|
21
|
+
* answers 500 by design — so this file's `deliveries` stays empty, its legs
|
|
22
|
+
* soft-skip, and the rows resolve `executed-pass`." A wire-shape scenario that
|
|
23
|
+
* never opened a delivery body went green.
|
|
24
|
+
*
|
|
25
|
+
* This is the class openwop#1513 (two legs, one effect identity) and
|
|
26
|
+
* openwop#1520 (many fakes, one public front) already opened. Those two supply
|
|
27
|
+
* the halves, and this composes them rather than reimplementing either:
|
|
28
|
+
*
|
|
29
|
+
* - ROUTING is `front-mux.ts`. Several receivers can be alive behind one
|
|
30
|
+
* front; each registers its handler under its nonce and calls `routeFronted`
|
|
31
|
+
* first, so a delivery reaches the receiver it was ADDRESSED to whichever
|
|
32
|
+
* listener happens to hold the port.
|
|
33
|
+
* - IDENTITY is here, and it is why the nonce is UNCONDITIONAL rather than
|
|
34
|
+
* `frontedEndpoint`'s "only when this fake does not own the pinned port".
|
|
35
|
+
* Routing alone does not close a webhook: a bare front is one identity
|
|
36
|
+
* shared ACROSS TIME, so a retry aimed at a finished exercise arrives
|
|
37
|
+
* indistinguishable from this one's traffic. With the nonce always present
|
|
38
|
+
* that retry addresses a nonce this listener does not serve, `routeFronted`
|
|
39
|
+
* answers it 404, and it is counted as `foreign()` — never as a delivery.
|
|
40
|
+
*
|
|
41
|
+
* What this does NOT do is relax any gate. The destination is still the
|
|
42
|
+
* operator's own front or loopback; `resolvePublicFront` still refuses a
|
|
43
|
+
* non-https or private front; `receiverBinding` still binds loopback unless the
|
|
44
|
+
* operator declared otherwise. Only the PATH changed.
|
|
45
|
+
*
|
|
46
|
+
* @see lib/front-mux.ts (routing), lib/effect-receiver.ts (the same nonce idea
|
|
47
|
+
* for an outbound effect's Layer-2 identity)
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { randomBytes } from 'node:crypto';
|
|
51
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
52
|
+
import { FRONT_MUX_PREFIX, registerBehindFront, routeFronted, unregisterBehindFront } from './front-mux.js';
|
|
53
|
+
import { receiverBinding, resolvePublicFront } from './webhook-receiver.js';
|
|
54
|
+
|
|
55
|
+
/** The operator's public front for the suite's webhook receiver. */
|
|
56
|
+
export const WEBHOOK_FRONT_ENV = 'OPENWOP_WEBHOOK_RECEIVER_URL';
|
|
57
|
+
|
|
58
|
+
type HeaderBag = Record<string, string | string[] | undefined>;
|
|
59
|
+
|
|
60
|
+
/** One request addressed to THIS exercise. */
|
|
61
|
+
export interface ScopedHit {
|
|
62
|
+
/** The path as this receiver sees it — the `/fx/<nonce>` prefix already stripped. */
|
|
63
|
+
readonly path: string;
|
|
64
|
+
readonly method: string;
|
|
65
|
+
readonly headers: HeaderBag;
|
|
66
|
+
readonly body: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ScopedReceiver {
|
|
70
|
+
readonly server: Server;
|
|
71
|
+
/**
|
|
72
|
+
* The destination to REGISTER with the host: the public front when one is
|
|
73
|
+
* wired, else this listener's own address — this exercise's nonce path
|
|
74
|
+
* appended either way.
|
|
75
|
+
*/
|
|
76
|
+
readonly url: string;
|
|
77
|
+
readonly tunnelled: boolean;
|
|
78
|
+
/** The local address this listener actually answers on — for failure detail. */
|
|
79
|
+
readonly localUrl: string;
|
|
80
|
+
/** What makes this exercise's destination, and so its subscription, its own. */
|
|
81
|
+
readonly nonce: string;
|
|
82
|
+
/** The port this listener bound — the pinned one when the operator pinned it. */
|
|
83
|
+
readonly port: number;
|
|
84
|
+
/**
|
|
85
|
+
* Requests that reached this listener bearing some OTHER exercise's nonce, or
|
|
86
|
+
* none at all. Reported so a scenario can say WHY it saw nothing; never handed
|
|
87
|
+
* to the scenario's recorder.
|
|
88
|
+
*/
|
|
89
|
+
foreign(): number;
|
|
90
|
+
close(): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start a receiver for exactly one exercise.
|
|
95
|
+
*
|
|
96
|
+
* `respond` is called ONLY for this exercise's own requests, and sees the path
|
|
97
|
+
* with the nonce prefix stripped — exactly what it would see on a listener of
|
|
98
|
+
* its own. It owns the response.
|
|
99
|
+
*/
|
|
100
|
+
export async function startScopedReceiver(
|
|
101
|
+
respond: (hit: ScopedHit, res: ServerResponse) => void,
|
|
102
|
+
): Promise<ScopedReceiver> {
|
|
103
|
+
const nonce = randomBytes(9).toString('hex');
|
|
104
|
+
let foreign = 0;
|
|
105
|
+
const own = (request: IncomingMessage, res: ServerResponse): void => {
|
|
106
|
+
const chunks: Buffer[] = [];
|
|
107
|
+
request.on('data', (c: Buffer) => chunks.push(c));
|
|
108
|
+
request.on('end', () => {
|
|
109
|
+
respond(
|
|
110
|
+
{
|
|
111
|
+
path: request.url ?? '/',
|
|
112
|
+
method: request.method ?? '',
|
|
113
|
+
headers: request.headers,
|
|
114
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
115
|
+
},
|
|
116
|
+
res,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
registerBehindFront(WEBHOOK_FRONT_ENV, nonce, own);
|
|
121
|
+
const server = createServer((request: IncomingMessage, res: ServerResponse) => {
|
|
122
|
+
// Counted BEFORE routing: `routeFronted` answers an unknown nonce itself and
|
|
123
|
+
// cannot report that it did, and a request that is not this exercise's must
|
|
124
|
+
// still be visible in the failure detail.
|
|
125
|
+
if (!(request.url ?? '/').startsWith(`${FRONT_MUX_PREFIX}${nonce}`)) foreign += 1;
|
|
126
|
+
if (routeFronted(WEBHOOK_FRONT_ENV, request, res)) return;
|
|
127
|
+
// Not an `/fx/` path at all: a stranger, or a host that dropped the path it
|
|
128
|
+
// was given. Answered, never recorded — a receiver that counts what it was
|
|
129
|
+
// not addressed to is the defect this helper exists to remove.
|
|
130
|
+
request.resume();
|
|
131
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ error: 'this receiver serves one conformance exercise; address its nonce path' }));
|
|
133
|
+
});
|
|
134
|
+
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
135
|
+
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
136
|
+
const binding = receiverBinding();
|
|
137
|
+
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
138
|
+
const addr = server.address();
|
|
139
|
+
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
140
|
+
const origin = `http://${binding.advertise}:${addr.port}`;
|
|
141
|
+
const front = resolvePublicFront(WEBHOOK_FRONT_ENV, origin);
|
|
142
|
+
return {
|
|
143
|
+
server,
|
|
144
|
+
url: `${front.url.replace(/\/+$/, '')}${FRONT_MUX_PREFIX}${nonce}`,
|
|
145
|
+
tunnelled: front.tunnelled,
|
|
146
|
+
localUrl: `${origin}${FRONT_MUX_PREFIX}${nonce}`,
|
|
147
|
+
nonce,
|
|
148
|
+
port: addr.port,
|
|
149
|
+
foreign: () => foreign,
|
|
150
|
+
close: () =>
|
|
151
|
+
new Promise<void>((resolve) => {
|
|
152
|
+
unregisterBehindFront(WEBHOOK_FRONT_ENV, nonce);
|
|
153
|
+
server.close(() => resolve());
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A destination for an exercise that must REGISTER a subscription but wants no
|
|
160
|
+
* delivery — the mint leg of `v2-bound-id-kinds`, say, which needs a 201 and a
|
|
161
|
+
* bound id and nothing else.
|
|
162
|
+
*
|
|
163
|
+
* Such a leg still has to honour the operator's front, or its registration is
|
|
164
|
+
* refused by an SSRF guard doing its job and the leg records `blocked` on every
|
|
165
|
+
* public cut. But handing it the front VERBATIM gives it the same identity as
|
|
166
|
+
* every other exercise, and a subscription is live from the 201 until the
|
|
167
|
+
* DELETE: anything the host fans out in that window lands on whichever listener
|
|
168
|
+
* holds the port and is read as that exercise's traffic.
|
|
169
|
+
*
|
|
170
|
+
* So it gets a nonce too. No receiver serves it, which is the point: a delivery
|
|
171
|
+
* to this destination is answered 404 by whichever scoped receiver owns the
|
|
172
|
+
* port rather than being absorbed into that exercise's record.
|
|
173
|
+
*/
|
|
174
|
+
export function unservedDestination(fallbackUrl: string): { url: string; tunnelled: boolean; nonce: string } {
|
|
175
|
+
const nonce = randomBytes(9).toString('hex');
|
|
176
|
+
const front = resolvePublicFront(WEBHOOK_FRONT_ENV, fallbackUrl);
|
|
177
|
+
return {
|
|
178
|
+
url: `${front.url.replace(/\/+$/, '')}${FRONT_MUX_PREFIX}${nonce}`,
|
|
179
|
+
tunnelled: front.tunnelled,
|
|
180
|
+
nonce,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Why a scoped receiver saw NO request of its own — the cause a `blocked` row
|
|
186
|
+
* must name (RFC 0148 §A: anything other than `executed-pass` MUST say why).
|
|
187
|
+
*
|
|
188
|
+
* A zero here is not by itself evidence that the host failed to deliver, and
|
|
189
|
+
* `foreign()` is what tells the two apart:
|
|
190
|
+
*
|
|
191
|
+
* - `foreign() > 0` — traffic DID reach this listener, addressed to another
|
|
192
|
+
* nonce or to no nonce at all. The path between the host and this process
|
|
193
|
+
* works, so "the host did not deliver" is not what was observed; what was
|
|
194
|
+
* observed is that nothing arrived under THIS exercise's identity. That is
|
|
195
|
+
* unmeasured, not unmet, and callers record `blocked`.
|
|
196
|
+
* - `foreign() === 0` — nothing reached this listener at all. That is still
|
|
197
|
+
* ambiguous (a front not wired to this process, or a host that never called
|
|
198
|
+
* out), and callers keep whichever disposition the leg already carried; this
|
|
199
|
+
* function only supplies the sentence that says which address was in play.
|
|
200
|
+
*
|
|
201
|
+
* Either way the record names the observation rather than a conclusion, so a
|
|
202
|
+
* reader can act on it without hand-probing production.
|
|
203
|
+
*/
|
|
204
|
+
export function noDeliveryCause(rx: ScopedReceiver, what = 'delivery'): string {
|
|
205
|
+
const where = rx.tunnelled
|
|
206
|
+
? `the registered destination is the public front ${rx.url} (${WEBHOOK_FRONT_ENV}) and this listener answers on ${rx.localUrl}, port ${rx.port}`
|
|
207
|
+
: `the registered destination is this listener at ${rx.url}, port ${rx.port}`;
|
|
208
|
+
const strangers = rx.foreign();
|
|
209
|
+
if (strangers > 0) {
|
|
210
|
+
return `no ${what} bearing this exercise's nonce ${rx.nonce} arrived, but ${strangers} request(s) DID reach this listener addressed elsewhere — so the path from the host to this process works and the absence is of this exercise's identity, not of traffic (${where})`;
|
|
211
|
+
}
|
|
212
|
+
return `no ${what} bearing this exercise's nonce ${rx.nonce} arrived, and nothing else reached this listener either — ${where}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* True when a zero observation is provably NOT a verdict about the host: other
|
|
217
|
+
* traffic reached this listener, so the absence is of this exercise's identity
|
|
218
|
+
* rather than of delivery. Callers record `blocked` (which denies certification
|
|
219
|
+
* exactly as a failure does, RFC 0168 §E.1) instead of convicting the host.
|
|
220
|
+
*/
|
|
221
|
+
export function absenceIsUnmeasured(rx: ScopedReceiver): boolean {
|
|
222
|
+
return rx.foreign() > 0;
|
|
223
|
+
}
|
package/src/lib/triggerBridge.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* @see spec/v1/trigger-bridge.md
|
|
21
21
|
* @see spec/v1/profiles.md (§openwop-trigger-bridge)
|
|
22
22
|
*/
|
|
23
|
+
import { randomBytes, randomInt } from 'node:crypto';
|
|
23
24
|
import { driver } from './driver.js';
|
|
24
25
|
import { deriveProfiles, type DiscoveryPayload } from './profiles.js';
|
|
25
26
|
|
|
@@ -70,5 +71,53 @@ export async function driveDelivery(
|
|
|
70
71
|
return (res.json as DeliveryResult | undefined) ?? {};
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A dedup key that belongs to ONE exercise (2.37.0).
|
|
76
|
+
*
|
|
77
|
+
* `trigger-bridge.md` §C-1 makes the dedup window a ≥24h FLOOR, so a LITERAL
|
|
78
|
+
* dedup key is not a fixture — it is a durable identity the host is required to
|
|
79
|
+
* remember across runs of this suite. Two exercises that hand the bridge the
|
|
80
|
+
* same key are ONE delivery by the spec's own rule, and the second one's row
|
|
81
|
+
* reads zero deliveries on a host doing exactly what it MUST. That is a suite
|
|
82
|
+
* defect and not a host defect, and it is the same failure the RFC 0158
|
|
83
|
+
* duplicate-delivery row had when two scenario files shared one effect identity
|
|
84
|
+
* (`lib/effect-receiver.ts`).
|
|
85
|
+
*
|
|
86
|
+
* `prefix` keeps a key readable in a host's own log; the random half is what
|
|
87
|
+
* makes it this exercise's. `randomBytes`, not `Date.now()`: two vitest workers
|
|
88
|
+
* can enter the same line in the same millisecond.
|
|
89
|
+
*/
|
|
90
|
+
export function freshDedupKey(prefix: string): string {
|
|
91
|
+
return `openwop-conformance-${prefix}-${randomBytes(9).toString('hex')}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The same per-exercise mint for a `stream` source, in the COORDINATE shape
|
|
96
|
+
* §F.5 keys on (2.37.0).
|
|
97
|
+
*
|
|
98
|
+
* `trigger-stream-cdc-sources.test.ts` handed the seam the literal
|
|
99
|
+
* `'events:3:99001'` and then asserted `deliveredCount === 1 || outcome ===
|
|
100
|
+
* 'delivered'` — the identical defect `freshDedupKey` above was written for,
|
|
101
|
+
* one file over, found when the enumeration was re-run against the merged
|
|
102
|
+
* tree. §C-1's window is a ≥24h floor and §F.5 reuses it verbatim, so the
|
|
103
|
+
* second run of that file against the same host, any time that day, hands the
|
|
104
|
+
* bridge broker coordinates it has already delivered; a CONFORMANT host
|
|
105
|
+
* collapses the exercise into the first run's outcome and the assertion
|
|
106
|
+
* convicts it. Cold host passes, warm host fails.
|
|
107
|
+
*
|
|
108
|
+
* `freshDedupKey` would not do here, and the difference is not cosmetic: §F.5
|
|
109
|
+
* says a stream event's dedup key SHOULD derive from
|
|
110
|
+
* `(topic, partition, offset)`, and the leg's own `req()` message asserts over
|
|
111
|
+
* exactly that keying. An opaque token would make the message describe
|
|
112
|
+
* something the call no longer does. So topic and partition stay fixed and the
|
|
113
|
+
* OFFSET is minted — which is precisely what makes a real broker's message a
|
|
114
|
+
* different message.
|
|
115
|
+
*/
|
|
116
|
+
export function freshStreamDedupKey(topic = 'events', partition = 3): string {
|
|
117
|
+
// A 2^44 offset space: distinct across every run this suite will make, and
|
|
118
|
+
// still a plausible broker offset rather than an opaque token.
|
|
119
|
+
return `${topic}:${partition}:${randomInt(2 ** 44)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
73
122
|
export const SUBSCRIPTION_STATES = ['active', 'paused', 'failed', 'dead-lettered'];
|
|
74
123
|
export const DELIVERY_OUTCOMES = ['delivered', 'retrying', 'dead-lettered'];
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - a legacy-only peer serves the 0.3 card (so the shape follows the revision,
|
|
16
16
|
* not the calendar);
|
|
17
17
|
* - `A2A-Version` absent ⇒ 0.3 semantics; unsupported ⇒ `-32009`
|
|
18
|
-
* `VERSION_NOT_SUPPORTED` with `supportedVersions
|
|
18
|
+
* `VERSION_NOT_SUPPORTED` with the supported versions (in `error.data`, an array of `Any` per A2A 1.0.1 §9.5: `google.rpc.ErrorInfo` `metadata.supportedVersions`, comma-joined); a 1.0-only peer
|
|
19
19
|
* rejects header-less requests;
|
|
20
20
|
* - `SendMessage` returns `{ task }` (the `SendMessageResponse` oneof) with
|
|
21
21
|
* `TASK_STATE_*`, `ROLE_*`, `Part` as a `oneof` (no `kind`), `status.timestamp`;
|
|
@@ -42,10 +42,20 @@ async function rpc(endpoint: string, method: string, params: unknown, version?:
|
|
|
42
42
|
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
43
43
|
if (version !== undefined) headers['A2A-Version'] = version;
|
|
44
44
|
const res = await fetch(`${endpoint}/a2a/jsonrpc`, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) });
|
|
45
|
-
const body = (await res.json()) as { result?: Record<string, unknown>; error?: { code: number; message: string; data?:
|
|
45
|
+
const body = (await res.json()) as { result?: Record<string, unknown>; error?: { code: number; message: string; data?: unknown } };
|
|
46
46
|
return { status: res.status, ...body };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A2A 1.0.1 §9.5: `error.data` is an array of ProtoJSON `Any`; the error type is the
|
|
51
|
+
* `google.rpc.ErrorInfo` element's `reason` (§10.6/§11.6). Returns that element, or
|
|
52
|
+
* undefined when `data` is not such an array (so a bare-object `data` fails the leg).
|
|
53
|
+
*/
|
|
54
|
+
const errorInfo = (e: { data?: unknown } | undefined): { reason?: string; domain?: string; metadata?: Record<string, string> } | undefined => {
|
|
55
|
+
if (!Array.isArray(e?.data)) return undefined;
|
|
56
|
+
return (e!.data as Array<Record<string, unknown>>).find((d) => d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo') as { reason?: string; domain?: string; metadata?: Record<string, string> } | undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
49
59
|
describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', () => {
|
|
50
60
|
// Explicit 1.0-first so a header-less GET returns the 1.0 card (the default
|
|
51
61
|
// is 0.3-first for today's 0.3 clients; both eras are spoken either way).
|
|
@@ -115,8 +125,10 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
|
|
|
115
125
|
const r = await rpc(peer.endpoint(), 'SendMessage', { message: { messageId: 'm2', role: 'ROLE_USER', parts: [{ text: 'hi' }] } }, '99.0');
|
|
116
126
|
expect(r.status, req('openwop.it.a2a-1-0-agent-card.an-unsupported-version-fails-32009-version-not-supported-with-supportedversions', 'RFC 0152 §A/§B/§C/§D', 'an unsupported version fails -32009 VERSION_NOT_SUPPORTED with supportedVersions[] (HTTP 400)')).toBe(400);
|
|
117
127
|
expect(r.error?.code).toBe(-32009);
|
|
118
|
-
expect(r.error?.data
|
|
119
|
-
expect(r.error?.
|
|
128
|
+
expect(Array.isArray(r.error?.data), req('openwop.it.a2a-1-0-agent-card.an-unsupported-version-fails-32009-version-not-supported-with-supportedversions', 'RFC 0152 §A/§B/§C/§D; A2A 1.0.1 §9.5', 'error.data is an array of Any carrying google.rpc.ErrorInfo')).toBe(true);
|
|
129
|
+
expect(errorInfo(r.error)?.reason).toBe('VERSION_NOT_SUPPORTED');
|
|
130
|
+
expect(errorInfo(r.error)?.domain).toBe('a2a-protocol.org');
|
|
131
|
+
expect(errorInfo(r.error)?.metadata?.['supportedVersions']).toBe('1.0,0.3'); // constructor order; ErrorInfo.metadata is map<string,string>
|
|
120
132
|
});
|
|
121
133
|
|
|
122
134
|
it('a 1.0-only peer rejects a header-less request (which is 0.3 by rule)', async () => {
|
|
@@ -125,7 +137,7 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
|
|
|
125
137
|
try {
|
|
126
138
|
const r = await rpc(only10.endpoint(), 'message/send', { message: { parts: [] } });
|
|
127
139
|
expect(r.error?.code, req('openwop.it.a2a-1-0-agent-card.a-1-0-only-peer-rejects-a-header-less-request-which-is-0-3-by-rule', 'RFC 0152 §A/§B/§C/§D', 'a 1.0-only peer rejects a header-less request (which is 0.3 by rule)')).toBe(-32009);
|
|
128
|
-
expect(r.error?.
|
|
140
|
+
expect(errorInfo(r.error)?.metadata?.['requested']).toBe('0.3');
|
|
129
141
|
} finally {
|
|
130
142
|
await only10.stop();
|
|
131
143
|
}
|
|
@@ -161,10 +173,10 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
|
|
|
161
173
|
expect((cancelled.result as { status: { state: string } }).status.state).toBe('TASK_STATE_CANCELED');
|
|
162
174
|
const again = await rpc(peer.endpoint(), 'CancelTask', { id }, '1.0');
|
|
163
175
|
expect(again.error?.code).toBe(-32002);
|
|
164
|
-
expect(again.error?.
|
|
176
|
+
expect(errorInfo(again.error)?.reason).toBe('TASK_NOT_CANCELABLE');
|
|
165
177
|
const missing = await rpc(peer.endpoint(), 'GetTask', { id: 'nope' }, '1.0');
|
|
166
178
|
expect(missing.error?.code).toBe(-32001);
|
|
167
|
-
expect(missing.error?.
|
|
179
|
+
expect(errorInfo(missing.error)?.reason).toBe('TASK_NOT_FOUND');
|
|
168
180
|
});
|
|
169
181
|
|
|
170
182
|
it('a 0.3 method name under a 1.0 header is method-not-found — loudly', async () => {
|