@openwop/openwop-conformance 1.0.0 → 1.1.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.
Files changed (80) 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 +293 -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 +2 -2
  19. package/schemas/security-advisory.schema.json +109 -0
  20. package/src/lib/a2a-fake-peer.ts +143 -56
  21. package/src/lib/behavior-gate.ts +68 -0
  22. package/src/lib/env.ts +10 -0
  23. package/src/lib/grpc-framing.test.ts +96 -0
  24. package/src/lib/grpc-framing.ts +76 -0
  25. package/src/lib/oidc-issuer.test.ts +328 -0
  26. package/src/lib/oidc-issuer.ts +241 -0
  27. package/src/lib/otel-collector-grpc.test.ts +191 -0
  28. package/src/lib/otel-collector.test.ts +303 -0
  29. package/src/lib/otel-collector.ts +318 -14
  30. package/src/lib/otlp-protobuf.test.ts +461 -0
  31. package/src/lib/otlp-protobuf.ts +529 -0
  32. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  33. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  34. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  37. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  38. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  39. package/src/scenarios/agentMetadata.test.ts +1 -0
  40. package/src/scenarios/agentPackExport.test.ts +1 -0
  41. package/src/scenarios/agentPackInstall.test.ts +1 -0
  42. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  43. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  44. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  45. package/src/scenarios/auth-mtls.test.ts +274 -0
  46. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  47. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  48. package/src/scenarios/bulk-cancel.test.ts +111 -0
  49. package/src/scenarios/configurable-schema.test.ts +48 -0
  50. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  51. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  52. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  53. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  54. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  55. package/src/scenarios/discovery.test.ts +183 -0
  56. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  57. package/src/scenarios/idempotency.test.ts +6 -0
  58. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  59. package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
  60. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  61. package/src/scenarios/metric-emission.test.ts +113 -0
  62. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  63. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  64. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  65. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  66. package/src/scenarios/pause-resume.test.ts +119 -0
  67. package/src/scenarios/production-backpressure.test.ts +342 -0
  68. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  69. package/src/scenarios/registry-public.test.ts +131 -0
  70. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  71. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  72. package/src/scenarios/restart-during-run.test.ts +177 -0
  73. package/src/scenarios/spec-corpus-validity.test.ts +54 -26
  74. package/src/scenarios/staleClaim.test.ts +3 -0
  75. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  76. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  77. package/src/scenarios/webhook-negative.test.ts +90 -0
  78. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  79. package/src/setup.ts +25 -1
  80. package/vitest.config.ts +5 -1
@@ -1,23 +1,33 @@
1
1
  /**
2
2
  * RFC 0008 §Conformance — scenario 5/6: memory cap enforcement.
3
3
  *
4
- * Verifies that a host enforces `capabilities.nodePackRuntimes.wasm.maxMemoryBytes`
5
- * by trapping (or otherwise terminating) a module that exceeds the cap
6
- * and emitting `cap.breached` with `kind: 'wasm-memory'`.
4
+ * Two-part scenario per RFCS/0008-wasm-abi.md §K (resource limits):
7
5
  *
8
- * The reference rust-hello pack is well-behaved and does not allocate
9
- * excessively, so this scenario is OBSERVATIONAL: it asserts the cap
10
- * is *declared* (the protocol requires it) and that if the host
11
- * advertises a value, the value is plausible.
6
+ * 1. **Discovery shape (observational, always-on)** host advertises
7
+ * `capabilities.nodePackRuntimes.wasm.maxMemoryBytes` as a plausible
8
+ * integer when WASM is supported.
9
+ * 2. **Positive path (fixture-gated)** when the deliberately-
10
+ * misbehaving Rust pack at `examples/packs/rust-misbehaving-memory/`
11
+ * is built and the host advertises the
12
+ * `conformance-wasm-pack-memory-cap-breach` fixture, invoking the
13
+ * `vendor.openwop.misbehaving.memory-bomb` typeId MUST emit
14
+ * `cap.breached` with `kind: 'wasm-memory'` and drive the run to
15
+ * terminal `failed`.
12
16
  *
13
- * Driving a real OOM requires a deliberately misbehaving pack. Such a
14
- * pack is filed as v1.x follow-up; the framework lives here.
17
+ * Until the misbehaving pack ships in a host's fixture set, the positive
18
+ * path soft-skips so the scenario stays green on hosts that haven't
19
+ * wired up the cap-emit behavior yet.
15
20
  *
16
21
  * @see RFCS/0008-wasm-abi.md §K (resource limits)
22
+ * @see schemas/run-event-payloads.schema.json §"capBreached" (kind enum includes wasm-memory)
17
23
  */
18
24
 
19
25
  import { describe, it, expect } from 'vitest';
20
26
  import { driver } from '../lib/driver.js';
27
+ import { pollUntilTerminal } from '../lib/polling.js';
28
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
29
+
30
+ const CAP_BREACH_FIXTURE = 'conformance-wasm-pack-memory-cap-breach';
21
31
 
22
32
  describe('wasm-pack-memory-cap: host advertises maxMemoryBytes', () => {
23
33
  it('capabilities.nodePackRuntimes.wasm.maxMemoryBytes is a plausible number', async () => {
@@ -41,3 +51,48 @@ describe('wasm-pack-memory-cap: host advertises maxMemoryBytes', () => {
41
51
  }
42
52
  });
43
53
  });
54
+
55
+ describe('wasm-pack-memory-cap: positive path via misbehaving pack', () => {
56
+ it('misbehaving pack triggers cap.breached with kind=wasm-memory and terminal failed', async () => {
57
+ if (!isFixtureAdvertised(CAP_BREACH_FIXTURE)) {
58
+ // eslint-disable-next-line no-console
59
+ console.warn(
60
+ `[wasm-pack-memory-cap] fixture ${CAP_BREACH_FIXTURE} not advertised; skipping positive path. ` +
61
+ 'Build the misbehaving pack at examples/packs/rust-misbehaving-memory/ and ensure the host serves the fixture.',
62
+ );
63
+ return;
64
+ }
65
+ const disco = await driver.get('/.well-known/openwop');
66
+ const wasm =
67
+ (disco.json as {
68
+ capabilities?: { nodePackRuntimes?: { wasm?: { supported?: boolean } } };
69
+ }).capabilities?.nodePackRuntimes?.wasm;
70
+ if (!wasm?.supported) return;
71
+
72
+ const create = await driver.post('/v1/runs', {
73
+ workflowId: CAP_BREACH_FIXTURE,
74
+ inputs: {},
75
+ });
76
+ expect(create.status).toBe(201);
77
+ const runId = (create.json as { runId: string }).runId;
78
+
79
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 15_000 });
80
+ expect(terminal.status, driver.describe(
81
+ 'RFCS/0008-wasm-abi.md §K',
82
+ 'WASM memory-cap breach MUST drive terminal `failed` (RFC 0008 §K: "kill the instance, emit cap.breached")',
83
+ )).toBe('failed');
84
+
85
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
86
+ const list = (events.json as { events?: Array<{ type: string; data?: unknown }> }).events ?? [];
87
+ const breachEvent = list.find((e) => e.type === 'cap.breached');
88
+ expect(breachEvent, driver.describe(
89
+ 'RFCS/0008-wasm-abi.md §K',
90
+ 'host MUST emit cap.breached when a WASM module exceeds its memory ceiling',
91
+ )).toBeDefined();
92
+ const breachKind = (breachEvent?.data as { kind?: string } | undefined)?.kind;
93
+ expect(breachKind, driver.describe(
94
+ 'RFCS/0008-wasm-abi.md §K',
95
+ 'cap.breached payload MUST carry kind: "wasm-memory" for memory-ceiling breaches',
96
+ )).toBe('wasm-memory');
97
+ });
98
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Webhook negative-path contract (webhooks.md + review hardening).
3
+ *
4
+ * Exercises three failure surfaces that the positive `webhook-signed-
5
+ * delivery.test.ts` doesn't cover:
6
+ * 1. SSRF guard — `POST /v1/webhooks` with a private-IP destination
7
+ * returns 400 `webhook_url_rejected` on hosts that enforce it.
8
+ * 2. URL validation — malformed `url` returns 400 `validation_error`.
9
+ * 3. Unregister of unknown subscription — `DELETE /v1/webhooks/{id}`
10
+ * returns 404 `subscription_not_found`.
11
+ *
12
+ * Capability-gated: skips when the host does not advertise
13
+ * `capabilities.webhooks.supported = true`.
14
+ *
15
+ * SSRF gating: hosts that don't implement the guard (or bypass it via
16
+ * `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true`) will accept the loopback URL
17
+ * with 201 — that's acceptable spec behavior, so the SSRF subtest
18
+ * soft-skips with a warning rather than failing.
19
+ *
20
+ * @see spec/v1/webhooks.md
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { driver } from '../lib/driver.js';
25
+
26
+ async function isWebhookSupported(): Promise<boolean> {
27
+ const disco = await driver.get('/.well-known/openwop');
28
+ const caps = (disco.json as { capabilities?: { webhooks?: { supported?: boolean } } })
29
+ .capabilities;
30
+ return caps?.webhooks?.supported === true;
31
+ }
32
+
33
+ describe('webhook-negative: SSRF guard rejects private destinations', () => {
34
+ it('host with SSRF guard returns 400 webhook_url_rejected for loopback', async () => {
35
+ if (!(await isWebhookSupported())) {
36
+ // eslint-disable-next-line no-console
37
+ console.warn('[webhook-negative] host does not advertise webhook support; skipping');
38
+ return;
39
+ }
40
+ const reg = await driver.post('/v1/webhooks', { url: 'http://127.0.0.1:65535/' });
41
+ if (reg.status === 201) {
42
+ // Host accepted — SSRF guard not implemented or bypassed.
43
+ // Soft-skip; this is acceptable per spec.
44
+ // eslint-disable-next-line no-console
45
+ console.warn(
46
+ '[webhook-negative] host accepts loopback destinations; SSRF guard not enforced',
47
+ );
48
+ // Cleanup the subscription so we don't leak state.
49
+ const body = reg.json as { subscriptionId?: string };
50
+ if (body.subscriptionId) {
51
+ await driver.delete(`/v1/webhooks/${encodeURIComponent(body.subscriptionId)}`);
52
+ }
53
+ return;
54
+ }
55
+ expect(reg.status, driver.describe(
56
+ 'webhooks.md + review §"Webhook SSRF guard"',
57
+ 'host with SSRF guard MUST return 400 for loopback / RFC1918 / link-local destinations',
58
+ )).toBe(400);
59
+ const body = reg.json as { error?: string };
60
+ expect(body.error).toBe('webhook_url_rejected');
61
+ });
62
+ });
63
+
64
+ describe('webhook-negative: validation errors', () => {
65
+ it('malformed url returns 400 validation_error', async () => {
66
+ if (!(await isWebhookSupported())) return;
67
+ const reg = await driver.post('/v1/webhooks', { url: 'not a url' });
68
+ expect([400, 422]).toContain(reg.status);
69
+ const body = reg.json as { error?: string };
70
+ expect(['validation_error', 'webhook_url_rejected']).toContain(body.error);
71
+ });
72
+
73
+ it('missing url returns 400 validation_error', async () => {
74
+ if (!(await isWebhookSupported())) return;
75
+ const reg = await driver.post('/v1/webhooks', { eventTypes: ['run.completed'] });
76
+ expect(reg.status).toBe(400);
77
+ const body = reg.json as { error?: string };
78
+ expect(body.error).toBe('validation_error');
79
+ });
80
+ });
81
+
82
+ describe('webhook-negative: unregister of unknown subscription', () => {
83
+ it('DELETE /v1/webhooks/{unknown} returns 404 subscription_not_found', async () => {
84
+ if (!(await isWebhookSupported())) return;
85
+ const del = await driver.delete('/v1/webhooks/wh-does-not-exist');
86
+ expect(del.status).toBe(404);
87
+ const body = del.json as { error?: string };
88
+ expect(body.error).toBe('subscription_not_found');
89
+ });
90
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * End-to-end webhook signed-delivery scenario (webhooks.md).
3
+ *
4
+ * Boots a local HTTP receiver, registers it via
5
+ * `POST /v1/webhooks`, drives a run, and verifies that:
6
+ * 1. Delivery arrives at the receiver.
7
+ * 2. `X-openwop-Signature-Algorithm: v1` header is present.
8
+ * 3. `X-openwop-Signature` is a valid HMAC-SHA256 of
9
+ * `${timestamp}.${rawBody}` under the subscription secret.
10
+ * 4. `X-openwop-Subscription-Id` matches the returned subscription id.
11
+ *
12
+ * Capability-gated: skips when the host does not advertise
13
+ * `capabilities.webhooks.supported = true`.
14
+ *
15
+ * Operator contract: hosts that implement a SSRF guard on
16
+ * `POST /v1/webhooks` (rejecting loopback / RFC1918 / link-local
17
+ * destinations to protect deployer infrastructure) MUST allow the test
18
+ * receiver. The SQLite reference host bypasses the guard when the
19
+ * `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` env var is set at boot. Test-only
20
+ * hosts SHOULD provide an equivalent opt-in. When the host rejects with
21
+ * `400 webhook_url_rejected`, this scenario skips with a warning.
22
+ *
23
+ * @see spec/v1/webhooks.md §"Signature scheme"
24
+ */
25
+
26
+ import { afterEach, describe, expect, it } from 'vitest';
27
+ import { createHmac } from 'node:crypto';
28
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
29
+ import { driver } from '../lib/driver.js';
30
+ import { pollUntilTerminal } from '../lib/polling.js';
31
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
32
+
33
+ interface DeliveredRequest {
34
+ readonly headers: Record<string, string>;
35
+ readonly body: string;
36
+ }
37
+
38
+ async function startReceiver(): Promise<{ server: Server; url: string; received: DeliveredRequest[] }> {
39
+ const received: DeliveredRequest[] = [];
40
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
41
+ const chunks: Buffer[] = [];
42
+ req.on('data', (c: Buffer) => chunks.push(c));
43
+ req.on('end', () => {
44
+ const body = Buffer.concat(chunks).toString('utf8');
45
+ const headers: Record<string, string> = {};
46
+ for (const [k, v] of Object.entries(req.headers)) {
47
+ if (typeof v === 'string') headers[k.toLowerCase()] = v;
48
+ else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(',');
49
+ }
50
+ received.push({ headers, body });
51
+ res.writeHead(204);
52
+ res.end();
53
+ });
54
+ });
55
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
56
+ const addr = server.address();
57
+ if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
58
+ return { server, url: `http://127.0.0.1:${addr.port}/`, received };
59
+ }
60
+
61
+ let activeServer: Server | null = null;
62
+ afterEach(async () => {
63
+ if (activeServer) {
64
+ await new Promise<void>((resolve) => activeServer!.close(() => resolve()));
65
+ activeServer = null;
66
+ }
67
+ });
68
+
69
+ async function isWebhookSupported(): Promise<boolean> {
70
+ const disco = await driver.get('/.well-known/openwop');
71
+ const caps = (disco.json as { capabilities?: { webhooks?: { supported?: boolean } } }).capabilities;
72
+ return caps?.webhooks?.supported === true;
73
+ }
74
+
75
+ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
76
+ it('host POSTs run events to subscriber with valid X-openwop-Signature', async () => {
77
+ if (!(await isWebhookSupported())) {
78
+ // eslint-disable-next-line no-console
79
+ console.warn('[webhook-signed-delivery] host does not advertise webhook support; skipping');
80
+ return;
81
+ }
82
+ if (!isFixtureAdvertised('conformance-noop')) {
83
+ // eslint-disable-next-line no-console
84
+ console.warn('[webhook-signed-delivery] conformance-noop not advertised; skipping');
85
+ return;
86
+ }
87
+
88
+ const receiver = await startReceiver();
89
+ activeServer = receiver.server;
90
+
91
+ // Register the webhook.
92
+ const reg = await driver.post('/v1/webhooks', { url: receiver.url });
93
+
94
+ // SSRF guard skip: if the host rejects loopback destinations,
95
+ // honor the operator contract and skip rather than fail.
96
+ if (reg.status === 400) {
97
+ const body = reg.json as { error?: string };
98
+ if (body.error === 'webhook_url_rejected') {
99
+ // eslint-disable-next-line no-console
100
+ console.warn(
101
+ '[webhook-signed-delivery] host SSRF guard rejected the loopback receiver; ' +
102
+ 'set OPENWOP_WEBHOOK_ALLOW_PRIVATE=true on the host (or equivalent) to run',
103
+ );
104
+ return;
105
+ }
106
+ }
107
+
108
+ expect(reg.status, driver.describe(
109
+ 'webhooks.md §"Register"',
110
+ 'POST /v1/webhooks MUST return 201 with subscriptionId + secret on success',
111
+ )).toBe(201);
112
+ const sub = reg.json as { subscriptionId: string; secret: string };
113
+ expect(typeof sub.subscriptionId).toBe('string');
114
+ expect(typeof sub.secret).toBe('string');
115
+ expect(sub.secret.length).toBeGreaterThan(0);
116
+
117
+ // Drive a run; the host MUST deliver events to the registered receiver.
118
+ const create = await driver.post('/v1/runs', { workflowId: 'conformance-noop' });
119
+ expect(create.status).toBe(201);
120
+ const runId = (create.json as { runId: string }).runId;
121
+ await pollUntilTerminal(runId, { timeoutMs: 10_000 });
122
+
123
+ // Allow a small grace period for fire-and-forget delivery to land.
124
+ await new Promise((resolve) => setTimeout(resolve, 500));
125
+
126
+ // Test-isolation note: when this scenario runs concurrently with
127
+ // other webhook-bearing scenarios against a stateful host, the
128
+ // host's webhook registry fans out EVERY run's events to EVERY
129
+ // registered subscription. Receivers in this scenario MAY observe
130
+ // deliveries from other tests' concurrent runs. Filter to events
131
+ // carrying THIS test's runId so the assertion checks the
132
+ // signature shape on a delivery the host emitted for THIS run.
133
+ const ourDeliveries = receiver.received.filter((d) => {
134
+ try {
135
+ const body = JSON.parse(d.body) as { runId?: unknown };
136
+ return body.runId === runId;
137
+ } catch {
138
+ return false;
139
+ }
140
+ });
141
+ expect(ourDeliveries.length, driver.describe(
142
+ 'webhooks.md §"Delivery"',
143
+ 'host MUST POST at least one event for THIS run to a registered subscriber after run.completed',
144
+ )).toBeGreaterThan(0);
145
+
146
+ // Validate the FIRST delivery's signature contract. Other deliveries
147
+ // share the same signing rules; checking one is sufficient.
148
+ const first = ourDeliveries[0]!;
149
+ expect(first.headers['x-openwop-signature-algorithm'], driver.describe(
150
+ 'webhooks.md §"Signature algorithm versioning"',
151
+ 'every delivery MUST set X-openwop-Signature-Algorithm: v1',
152
+ )).toBe('v1');
153
+ expect(first.headers['x-openwop-subscription-id']).toBe(sub.subscriptionId);
154
+
155
+ const timestamp = first.headers['x-openwop-signature-timestamp'];
156
+ expect(typeof timestamp).toBe('string');
157
+ expect((timestamp ?? '').length).toBeGreaterThan(0);
158
+
159
+ const signature = first.headers['x-openwop-signature'];
160
+ const expected = createHmac('sha256', sub.secret)
161
+ .update(`${timestamp}.${first.body}`, 'utf8')
162
+ .digest('hex');
163
+ expect(signature, driver.describe(
164
+ 'webhooks.md §"Signature scheme"',
165
+ 'X-openwop-Signature MUST be HMAC-SHA256(secret, `${timestamp}.${rawBody}`) hex',
166
+ )).toBe(expected);
167
+
168
+ // Body should parse as JSON with a run event shape.
169
+ const event = JSON.parse(first.body) as { type?: unknown; runId?: unknown };
170
+ expect(typeof event.type).toBe('string');
171
+ expect(event.runId).toBe(runId);
172
+
173
+ // Cleanup: unregister.
174
+ const del = await driver.delete(`/v1/webhooks/${encodeURIComponent(sub.subscriptionId)}`);
175
+ expect(del.status).toBeGreaterThanOrEqual(200);
176
+ expect(del.status).toBeLessThan(300);
177
+ });
178
+ });
package/src/setup.ts CHANGED
@@ -117,6 +117,30 @@ async function maybeStartOtelCollector(): Promise<void> {
117
117
  `Configure the host with OTEL_EXPORTER_OTLP_ENDPOINT=${collector.endpoint()} ` +
118
118
  `and OTEL_EXPORTER_OTLP_PROTOCOL=http/json.`,
119
119
  );
120
+
121
+ // Track 11 — opt into the parallel OTLP/gRPC collector when
122
+ // `OPENWOP_OTEL_COLLECTOR_GRPC=true`. Same span/metric store; hosts
123
+ // emitting via either transport surface in `getCollector().spans()`.
124
+ if (process.env.OPENWOP_OTEL_COLLECTOR_GRPC === 'true') {
125
+ const grpcPortEnv = process.env.OPENWOP_OTEL_COLLECTOR_GRPC_PORT;
126
+ const grpcRequestedPort = grpcPortEnv ? Number(grpcPortEnv) : 4317;
127
+ try {
128
+ await collector.startGrpc(grpcRequestedPort);
129
+ } catch (err) {
130
+ // eslint-disable-next-line no-console
131
+ console.warn(
132
+ `[openwop-conformance setup] OTLP/gRPC collector failed to bind on port ${grpcRequestedPort} ` +
133
+ `(${(err as Error).message ?? 'unknown'}); falling back to ephemeral port.`,
134
+ );
135
+ await collector.startGrpc(0);
136
+ }
137
+ // eslint-disable-next-line no-console
138
+ console.error(
139
+ `[openwop-conformance setup] OTLP/gRPC collector listening at ${collector.grpcEndpoint()}. ` +
140
+ `Configure the host with OTEL_EXPORTER_OTLP_ENDPOINT=${collector.grpcEndpoint()} ` +
141
+ `and OTEL_EXPORTER_OTLP_PROTOCOL=grpc.`,
142
+ );
143
+ }
120
144
  }
121
145
 
122
146
  /**
@@ -163,7 +187,7 @@ async function maybeStartA2AFakePeer(): Promise<void> {
163
187
  // eslint-disable-next-line no-console
164
188
  console.error(
165
189
  `[openwop-conformance setup] A2A fake peer listening at ${peer.endpoint()}. ` +
166
- `AgentCard at ${peer.endpoint()}/agent.json.`,
190
+ `AgentCard at ${peer.endpoint()}/.well-known/agent-card.json.`,
167
191
  );
168
192
  }
169
193
 
package/vitest.config.ts CHANGED
@@ -2,7 +2,11 @@ import { defineConfig } from 'vitest/config';
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
- include: ['src/scenarios/**/*.test.ts'],
5
+ // `src/scenarios/**/*.test.ts` are the host-targeted conformance
6
+ // scenarios; `src/lib/**/*.test.ts` are server-free unit tests for
7
+ // suite-internal helpers (e.g., the synthetic OIDC issuer harness)
8
+ // that don't depend on OPENWOP_BASE_URL.
9
+ include: ['src/scenarios/**/*.test.ts', 'src/lib/**/*.test.ts'],
6
10
  testTimeout: 30_000,
7
11
  hookTimeout: 30_000,
8
12
  globals: true,