@openwop/openwop-conformance 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +342 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +20 -4
  19. package/schemas/run-event.schema.json +2 -1
  20. package/schemas/security-advisory.schema.json +109 -0
  21. package/src/lib/a2a-fake-peer.ts +143 -56
  22. package/src/lib/behavior-gate.ts +107 -0
  23. package/src/lib/env.ts +37 -0
  24. package/src/lib/grpc-framing.test.ts +96 -0
  25. package/src/lib/grpc-framing.ts +76 -0
  26. package/src/lib/oidc-issuer.test.ts +328 -0
  27. package/src/lib/oidc-issuer.ts +241 -0
  28. package/src/lib/otel-collector-grpc.test.ts +191 -0
  29. package/src/lib/otel-collector.test.ts +303 -0
  30. package/src/lib/otel-collector.ts +318 -14
  31. package/src/lib/otlp-protobuf.test.ts +461 -0
  32. package/src/lib/otlp-protobuf.ts +529 -0
  33. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  34. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  37. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  38. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  39. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  40. package/src/scenarios/agentMetadata.test.ts +1 -0
  41. package/src/scenarios/agentPackExport.test.ts +1 -0
  42. package/src/scenarios/agentPackInstall.test.ts +1 -0
  43. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  44. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  45. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  46. package/src/scenarios/auth-mtls.test.ts +274 -0
  47. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  48. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  49. package/src/scenarios/bulk-cancel.test.ts +111 -0
  50. package/src/scenarios/configurable-schema.test.ts +48 -0
  51. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  52. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  53. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  54. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  55. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  56. package/src/scenarios/discovery.test.ts +183 -0
  57. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  58. package/src/scenarios/idempotency.test.ts +6 -0
  59. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  60. package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
  61. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  62. package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
  63. package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
  64. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
  65. package/src/scenarios/metric-emission.test.ts +113 -0
  66. package/src/scenarios/multi-region-idempotency.test.ts +39 -4
  67. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  68. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  69. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  70. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  71. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
  72. package/src/scenarios/pause-resume.test.ts +119 -0
  73. package/src/scenarios/production-backpressure.test.ts +342 -0
  74. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  75. package/src/scenarios/registry-public.test.ts +222 -0
  76. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  77. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  78. package/src/scenarios/restart-during-run.test.ts +177 -0
  79. package/src/scenarios/spec-corpus-validity.test.ts +59 -26
  80. package/src/scenarios/staleClaim.test.ts +3 -0
  81. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  82. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  83. package/src/scenarios/webhook-negative.test.ts +90 -0
  84. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  85. package/src/setup.ts +25 -1
  86. package/vitest.config.ts +5 -1
@@ -31,13 +31,35 @@
31
31
  */
32
32
 
33
33
  import { createServer, type Server } from 'node:http';
34
+ import { createServer as createHttp2Server, type Http2Server, type ServerHttp2Stream, type IncomingHttpHeaders } from 'node:http2';
34
35
  import type { AddressInfo } from 'node:net';
36
+ import { frameMessage, unframeMessages } from './grpc-framing.js';
37
+ import {
38
+ decodeExportTraceServiceRequest,
39
+ decodeExportMetricsServiceRequest,
40
+ } from './otlp-protobuf.js';
35
41
 
36
42
  export interface OtelAttribute {
37
43
  readonly key: string;
38
44
  readonly value: string | number | boolean | null | readonly unknown[];
39
45
  }
40
46
 
47
+ /**
48
+ * Narrow structural type the `_ingestTraces` helper accepts. Both the
49
+ * JSON parser (`JSON.parse(body) → unknown`, cast to this shape) and
50
+ * the protobuf decoder (`decodeExportTraceServiceRequest → JsonExport-
51
+ * TraceServiceRequest`) flow through this interface without needing
52
+ * `as unknown as Record<string, unknown>` double-casts.
53
+ */
54
+ export interface TracesIngest {
55
+ resourceSpans?: unknown;
56
+ }
57
+
58
+ /** Same idea for the metrics ingest path. */
59
+ export interface MetricsIngest {
60
+ resourceMetrics?: unknown;
61
+ }
62
+
41
63
  export interface CapturedSpan {
42
64
  readonly traceId: string;
43
65
  readonly spanId: string;
@@ -97,6 +119,12 @@ export class OtelCollector {
97
119
  private readonly _metrics: CapturedMetric[] = [];
98
120
  private _server: Server | null = null;
99
121
  private _boundPort: number = 0;
122
+ // OTLP/gRPC parallel server (Track 11). Boots when `startGrpc()` is
123
+ // called. Shares `_spans` + `_metrics` with the HTTP collector so
124
+ // scenarios can assert on captured data regardless of which transport
125
+ // the host used to emit.
126
+ private _grpcServer: Http2Server | null = null;
127
+ private _grpcBoundPort: number = 0;
100
128
 
101
129
  /**
102
130
  * Start the collector. If `port` is `0` (or unset), an ephemeral port
@@ -124,6 +152,53 @@ export class OtelCollector {
124
152
  });
125
153
  }
126
154
 
155
+ /**
156
+ * Start the OTLP/gRPC server alongside the HTTP one. Uses Node's
157
+ * stdlib `http2` (h2c — cleartext HTTP/2). Same `_spans` + `_metrics`
158
+ * store; spans captured here are visible to `spans()` /
159
+ * `spansByName()` / etc. exactly like HTTP-emitted ones.
160
+ *
161
+ * @param port Bind port; `0` (default) picks an ephemeral port.
162
+ */
163
+ async startGrpc(port: number = 0): Promise<void> {
164
+ return new Promise((resolve, reject) => {
165
+ const server = createHttp2Server();
166
+ server.on('error', reject);
167
+ server.on('stream', (stream, headers) => {
168
+ this._handleGrpcStream(stream, headers).catch((err) => {
169
+ // eslint-disable-next-line no-console
170
+ console.error('[otel-collector-grpc] stream error:', err);
171
+ if (!stream.closed) {
172
+ stream.respond(
173
+ {
174
+ ':status': 200,
175
+ 'content-type': 'application/grpc+proto',
176
+ 'grpc-status': '13', // INTERNAL
177
+ 'grpc-message': String((err as Error).message ?? err),
178
+ },
179
+ { endStream: true },
180
+ );
181
+ }
182
+ });
183
+ });
184
+ server.listen(port, '127.0.0.1', () => {
185
+ const addr = server.address() as AddressInfo;
186
+ this._grpcServer = server;
187
+ this._grpcBoundPort = addr.port;
188
+ resolve();
189
+ });
190
+ });
191
+ }
192
+
193
+ async stopGrpc(): Promise<void> {
194
+ if (!this._grpcServer) return;
195
+ const server = this._grpcServer;
196
+ this._grpcServer = null;
197
+ return new Promise((resolve, reject) => {
198
+ server.close((err) => (err ? reject(err) : resolve()));
199
+ });
200
+ }
201
+
127
202
  boundPort(): number {
128
203
  return this._boundPort;
129
204
  }
@@ -132,6 +207,15 @@ export class OtelCollector {
132
207
  return `http://127.0.0.1:${this._boundPort}`;
133
208
  }
134
209
 
210
+ grpcBoundPort(): number {
211
+ return this._grpcBoundPort;
212
+ }
213
+
214
+ /** h2c base URL (no scheme distinction — gRPC clients use `:authority` form). */
215
+ grpcEndpoint(): string {
216
+ return `http://127.0.0.1:${this._grpcBoundPort}`;
217
+ }
218
+
135
219
  reset(): void {
136
220
  this._spans.length = 0;
137
221
  this._metrics.length = 0;
@@ -167,25 +251,111 @@ export class OtelCollector {
167
251
  res.writeHead(405).end();
168
252
  return;
169
253
  }
254
+ // Defense-in-depth: cap inbound body size before buffering. The
255
+ // collector runs in the conformance suite process; a runaway host
256
+ // (or operator misconfig) emitting a multi-GB OTLP payload would
257
+ // otherwise OOM the suite. 16 MiB is generous for normal OTLP
258
+ // traffic (a 100-span batch with 50-attribute payloads runs ~50 KiB
259
+ // in JSON) but bounds the worst case. Hosts hitting this limit
260
+ // should batch smaller; the cap is suite-side, not normative.
261
+ const MAX_BODY_BYTES = 16 * 1024 * 1024;
170
262
  const chunks: Buffer[] = [];
263
+ let received = 0;
171
264
  for await (const c of req) {
172
- chunks.push(c as Buffer);
265
+ const buf = c as Buffer;
266
+ received += buf.length;
267
+ if (received > MAX_BODY_BYTES) {
268
+ res
269
+ .writeHead(413, { 'Content-Type': 'application/json' })
270
+ .end(
271
+ JSON.stringify({
272
+ error: 'payload_too_large',
273
+ message: `OTLP body exceeds ${MAX_BODY_BYTES} bytes; reduce batch size or split exports`,
274
+ }),
275
+ );
276
+ req.destroy();
277
+ return;
278
+ }
279
+ chunks.push(buf);
173
280
  }
174
- const body = Buffer.concat(chunks).toString('utf8');
175
- let payload: Record<string, unknown> = {};
176
- try {
177
- payload = body.length > 0 ? (JSON.parse(body) as Record<string, unknown>) : {};
178
- } catch {
179
- // Protobuf-encoded OTLP arrives as binary; we currently support JSON only.
180
- // Hosts that emit protobuf-encoded OTLP need to be configured for
181
- // `OTEL_EXPORTER_OTLP_PROTOCOL=http/json`.
182
- res.writeHead(415).end('OTLP/HTTP-JSON only');
281
+ const body = Buffer.concat(chunks);
282
+ const contentType = (req.headers['content-type'] ?? '').toLowerCase();
283
+ const isProtobuf =
284
+ contentType.includes('application/x-protobuf') ||
285
+ contentType.includes('application/protobuf');
286
+ const isJson = contentType.includes('application/json') || contentType === '';
287
+
288
+ const isTracesRoute = req.url?.includes('/v1/traces') === true;
289
+ const isMetricsRoute = req.url?.includes('/v1/metrics') === true;
290
+
291
+ // Both the JSON parser and the protobuf decoder produce objects
292
+ // with the same structural shape. Typing `payload` as the union
293
+ // narrow-property interface (instead of `Record<string, unknown>`)
294
+ // lets both paths flow in without `as unknown as` double-casts.
295
+ let payload: TracesIngest & MetricsIngest = {};
296
+
297
+ if (isProtobuf) {
298
+ // OTLP/HTTP-protobuf — added in the Track 11 follow-up. Decode the
299
+ // binary message via the in-suite hand-rolled decoder
300
+ // (`otlp-protobuf.ts`) and produce the same JSON-shaped object
301
+ // the existing `_ingestTraces` / `_ingestMetrics` already consume.
302
+ try {
303
+ if (isTracesRoute) {
304
+ payload = decodeExportTraceServiceRequest(
305
+ new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
306
+ );
307
+ } else if (isMetricsRoute) {
308
+ payload = decodeExportMetricsServiceRequest(
309
+ new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
310
+ );
311
+ } else {
312
+ // /v1/logs or unknown — ack and ignore so the exporter doesn't retry.
313
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end('{}');
314
+ return;
315
+ }
316
+ } catch (err) {
317
+ res
318
+ .writeHead(400, { 'Content-Type': 'application/json' })
319
+ .end(
320
+ JSON.stringify({
321
+ error: 'invalid_protobuf',
322
+ message: `OTLP/HTTP-protobuf decode failed: ${(err as Error).message ?? 'unknown'}`,
323
+ }),
324
+ );
325
+ return;
326
+ }
327
+ } else if (isJson) {
328
+ try {
329
+ payload =
330
+ body.length > 0
331
+ ? (JSON.parse(body.toString('utf8')) as TracesIngest & MetricsIngest)
332
+ : {};
333
+ } catch {
334
+ res
335
+ .writeHead(400, { 'Content-Type': 'application/json' })
336
+ .end(
337
+ JSON.stringify({
338
+ error: 'invalid_json',
339
+ message: 'OTLP/HTTP-JSON body did not parse',
340
+ }),
341
+ );
342
+ return;
343
+ }
344
+ } else {
345
+ res
346
+ .writeHead(415, { 'Content-Type': 'application/json' })
347
+ .end(
348
+ JSON.stringify({
349
+ error: 'unsupported_media_type',
350
+ message: `OTLP collector accepts application/json or application/x-protobuf; got "${contentType}"`,
351
+ }),
352
+ );
183
353
  return;
184
354
  }
185
355
 
186
- if (req.url?.includes('/v1/traces')) {
356
+ if (isTracesRoute) {
187
357
  this._ingestTraces(payload);
188
- } else if (req.url?.includes('/v1/metrics')) {
358
+ } else if (isMetricsRoute) {
189
359
  this._ingestMetrics(payload);
190
360
  } else {
191
361
  // Ignore /v1/logs and unknown paths; respond OK so the exporter doesn't retry.
@@ -194,7 +364,141 @@ export class OtelCollector {
194
364
  res.writeHead(200, { 'Content-Type': 'application/json' }).end('{}');
195
365
  }
196
366
 
197
- private _ingestTraces(payload: Record<string, unknown>): void {
367
+ /**
368
+ * Handle a single OTLP/gRPC HTTP/2 stream. gRPC unary Export calls
369
+ * deliver one length-prefixed protobuf message in the request body
370
+ * and expect a length-prefixed Empty response message plus a
371
+ * `grpc-status: 0` trailer.
372
+ *
373
+ * Routing per the OTLP service definitions:
374
+ * POST /opentelemetry.proto.collector.trace.v1.TraceService/Export → traces
375
+ * POST /opentelemetry.proto.collector.metrics.v1.MetricsService/Export → metrics
376
+ *
377
+ * @see https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
378
+ * @see https://opentelemetry.io/docs/specs/otlp/#otlpgrpc
379
+ */
380
+ private async _handleGrpcStream(
381
+ stream: ServerHttp2Stream,
382
+ headers: IncomingHttpHeaders,
383
+ ): Promise<void> {
384
+ const path = String(headers[':path'] ?? '');
385
+ const method = String(headers[':method'] ?? '');
386
+ const contentType = String(headers['content-type'] ?? '').toLowerCase();
387
+
388
+ if (method !== 'POST' || !contentType.startsWith('application/grpc')) {
389
+ // gRPC servers respond on HTTP/2 with :status 200 and signal the
390
+ // error via grpc-status + grpc-message trailers. Non-gRPC clients
391
+ // can't easily reach this endpoint so the error mode is mostly
392
+ // defensive against operator misconfiguration.
393
+ stream.respond(
394
+ {
395
+ ':status': 200,
396
+ 'content-type': 'application/grpc+proto',
397
+ 'grpc-status': '3', // INVALID_ARGUMENT
398
+ 'grpc-message': `expected POST with content-type application/grpc+proto, got ${method} ${contentType}`,
399
+ },
400
+ { endStream: true },
401
+ );
402
+ return;
403
+ }
404
+
405
+ // Read the request body. gRPC unary requests have a single
406
+ // length-prefixed frame; defensively bound the total size so a
407
+ // runaway peer can't OOM the suite.
408
+ const MAX_BODY_BYTES = 16 * 1024 * 1024;
409
+ const chunks: Buffer[] = [];
410
+ let received = 0;
411
+ for await (const c of stream) {
412
+ const buf = c as Buffer;
413
+ received += buf.length;
414
+ if (received > MAX_BODY_BYTES) {
415
+ stream.respond(
416
+ {
417
+ ':status': 200,
418
+ 'content-type': 'application/grpc+proto',
419
+ 'grpc-status': '8', // RESOURCE_EXHAUSTED
420
+ 'grpc-message': `OTLP body exceeds ${MAX_BODY_BYTES} bytes`,
421
+ },
422
+ { endStream: true },
423
+ );
424
+ return;
425
+ }
426
+ chunks.push(buf);
427
+ }
428
+ const body = Buffer.concat(chunks);
429
+
430
+ let frames: Uint8Array[];
431
+ try {
432
+ frames = unframeMessages(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
433
+ } catch (err) {
434
+ stream.respond(
435
+ {
436
+ ':status': 200,
437
+ 'content-type': 'application/grpc+proto',
438
+ 'grpc-status': '3', // INVALID_ARGUMENT
439
+ 'grpc-message': `gRPC frame parse failed: ${(err as Error).message ?? 'unknown'}`,
440
+ },
441
+ { endStream: true },
442
+ );
443
+ return;
444
+ }
445
+
446
+ // gRPC URLs are `/<package>.<Service>/<Method>`. The Service is
447
+ // package-qualified so the character before "TraceService" /
448
+ // "MetricsService" is `.` (part of the package), not `/`. Match on
449
+ // the Service.Method suffix.
450
+ const isTracesRoute = path.endsWith('TraceService/Export');
451
+ const isMetricsRoute = path.endsWith('MetricsService/Export');
452
+
453
+ if (isTracesRoute || isMetricsRoute) {
454
+ try {
455
+ for (const frame of frames) {
456
+ if (isTracesRoute) {
457
+ const payload = decodeExportTraceServiceRequest(frame);
458
+ this._ingestTraces(payload as TracesIngest);
459
+ } else if (isMetricsRoute) {
460
+ const payload = decodeExportMetricsServiceRequest(frame);
461
+ this._ingestMetrics(payload as MetricsIngest);
462
+ }
463
+ }
464
+ } catch (err) {
465
+ stream.respond(
466
+ {
467
+ ':status': 200,
468
+ 'content-type': 'application/grpc+proto',
469
+ 'grpc-status': '3', // INVALID_ARGUMENT
470
+ 'grpc-message': `OTLP protobuf decode failed: ${(err as Error).message ?? 'unknown'}`,
471
+ },
472
+ { endStream: true },
473
+ );
474
+ return;
475
+ }
476
+ }
477
+ // Unknown service/method paths fall through to a success response
478
+ // so the exporter doesn't retry on /v1/logs or similar surfaces we
479
+ // don't capture; spans/metrics stay un-ingested.
480
+
481
+ // Per OTLP spec, the Export response is an empty ExportTraceServiceResponse
482
+ // (or ExportMetricsServiceResponse) — zero-byte protobuf. gRPC over
483
+ // HTTP/2 sends headers + body + trailers; in Node's API we set
484
+ // `waitForTrailers` on respond() so the stream emits a
485
+ // `wantTrailers` event after the body, at which point we call
486
+ // sendTrailers() and the stream closes cleanly.
487
+ const responseBody = frameMessage(new Uint8Array(0));
488
+ stream.on('wantTrailers', () => {
489
+ stream.sendTrailers({ 'grpc-status': '0' });
490
+ });
491
+ stream.respond(
492
+ {
493
+ ':status': 200,
494
+ 'content-type': 'application/grpc+proto',
495
+ },
496
+ { waitForTrailers: true },
497
+ );
498
+ stream.end(responseBody);
499
+ }
500
+
501
+ private _ingestTraces(payload: TracesIngest): void {
198
502
  const rs = (payload.resourceSpans ?? []) as ReadonlyArray<Record<string, unknown>>;
199
503
  for (const r of rs) {
200
504
  const resourceAttrs = decodeAttributes(
@@ -222,7 +526,7 @@ export class OtelCollector {
222
526
  }
223
527
  }
224
528
 
225
- private _ingestMetrics(payload: Record<string, unknown>): void {
529
+ private _ingestMetrics(payload: MetricsIngest): void {
226
530
  const rm = (payload.resourceMetrics ?? []) as ReadonlyArray<Record<string, unknown>>;
227
531
  for (const r of rm) {
228
532
  const sm = (r.scopeMetrics ?? []) as ReadonlyArray<Record<string, unknown>>;