@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
@@ -3,24 +3,29 @@
3
3
  *
4
4
  * The A2A protocol (https://a2a-protocol.org/) defines an `AgentCard` for
5
5
  * discovery plus a Task lifecycle whose `TaskState` enum drives most of
6
- * the conformance burden. We expose just enough of the HTTP+JSON
6
+ * the conformance burden. We expose just enough of A2A v0.3 JSON-RPC
7
7
  * transport to let conformance scenarios drive the host through the
8
8
  * four documented drift points from `spec/v1/a2a-integration.md`
9
9
  * §"State projection".
10
10
  *
11
- * Endpoints (minimal):
12
- * GET /agent.json — AgentCard
13
- * POST /tasks — create a task; returns { taskId, state: 'SUBMITTED' }
14
- * GET /tasks/{taskId} — poll task state + last message
15
- * POST /tasks/{taskId}/messages append a message (used by host to resume an INPUT_REQUIRED task)
11
+ * Endpoints (A2A v0.3 wire shape):
12
+ * GET /.well-known/agent-card.json — AgentCard at the spec-mandated
13
+ * well-known path (per
14
+ * @a2a-js/sdk@0.3.13 `AGENT_CARD_PATH`)
15
+ * POST /a2a/jsonrpc JSON-RPC dispatch:
16
+ * - method `message/send` →
17
+ * creates a Task, returns Task obj
18
+ * - method `tasks/get` → fetches Task
16
19
  *
17
20
  * Test fixtures set the *next* state transition via `setNextState(...)`
18
21
  * so a single scenario can walk the peer through SUBMITTED → WORKING →
19
22
  * INPUT_REQUIRED → COMPLETED (or AUTH_REQUIRED, or REJECTED) without
20
- * implementing a real agent.
23
+ * implementing a real agent. The internal API uses the UPPERCASE enum
24
+ * from openwop's a2a-integration.md (which references the gRPC enum);
25
+ * wire responses use the v0.3 JSON-RPC lowercase-hyphen form.
21
26
  *
22
27
  * @see spec/v1/a2a-integration.md §"State projection"
23
- * @see https://a2a-protocol.org/latest/specification/
28
+ * @see https://a2a-protocol.org/v0.3.0/specification
24
29
  */
25
30
 
26
31
  import { createServer, type Server } from 'node:http';
@@ -37,16 +42,32 @@ export type A2ATaskState =
37
42
  | 'CANCELED'
38
43
  | 'REJECTED';
39
44
 
45
+ /** Translate internal UPPERCASE state to A2A v0.3 wire form (lowercase + hyphen). */
46
+ function wireState(s: A2ATaskState): string {
47
+ switch (s) {
48
+ case 'UNSPECIFIED': return 'unknown';
49
+ case 'SUBMITTED': return 'submitted';
50
+ case 'WORKING': return 'working';
51
+ case 'INPUT_REQUIRED': return 'input-required';
52
+ case 'AUTH_REQUIRED': return 'auth-required';
53
+ case 'COMPLETED': return 'completed';
54
+ case 'FAILED': return 'failed';
55
+ case 'CANCELED': return 'canceled';
56
+ case 'REJECTED': return 'rejected';
57
+ }
58
+ }
59
+
40
60
  interface A2ATask {
41
- taskId: string;
61
+ id: string;
62
+ contextId: string;
42
63
  state: A2ATaskState;
43
- messages: Array<{ role: 'user' | 'agent'; content: unknown }>;
44
- metadata?: Record<string, unknown>;
64
+ history: Array<{ role: 'user' | 'agent'; parts: unknown[] }>;
45
65
  }
46
66
 
47
67
  export interface A2APeerInvocation {
48
68
  readonly method: string;
49
69
  readonly path: string;
70
+ readonly rpcMethod: string | null;
50
71
  readonly body: unknown;
51
72
  readonly timestamp: number;
52
73
  }
@@ -61,7 +82,14 @@ export class A2AFakePeer {
61
82
 
62
83
  async start(port: number = 0): Promise<void> {
63
84
  return new Promise((resolve, reject) => {
64
- const server = createServer((req, res) => this._handle(req, res));
85
+ const server = createServer((req, res) => {
86
+ this._handle(req, res).catch((err) => {
87
+ if (!res.headersSent) {
88
+ res.writeHead(500, { 'Content-Type': 'application/json' });
89
+ res.end(JSON.stringify({ error: String(err) }));
90
+ }
91
+ });
92
+ });
65
93
  server.on('error', reject);
66
94
  server.listen(port, '127.0.0.1', () => {
67
95
  const addr = server.address() as AddressInfo;
@@ -121,6 +149,21 @@ export class A2AFakePeer {
121
149
  return this._tasks.get(taskId);
122
150
  }
123
151
 
152
+ private _taskToWire(t: A2ATask): Record<string, unknown> {
153
+ return {
154
+ id: t.id,
155
+ kind: 'task',
156
+ contextId: t.contextId,
157
+ status: { state: wireState(t.state) },
158
+ history: t.history.map((m) => ({
159
+ kind: 'message',
160
+ role: m.role,
161
+ parts: m.parts,
162
+ messageId: `msg-${t.id}-${t.history.indexOf(m)}`,
163
+ })),
164
+ };
165
+ }
166
+
124
167
  private async _handle(
125
168
  req: import('node:http').IncomingMessage,
126
169
  res: import('node:http').ServerResponse,
@@ -138,82 +181,126 @@ export class A2AFakePeer {
138
181
  }
139
182
  }
140
183
 
184
+ const rpcMethod =
185
+ body && typeof body === 'object' && body !== null && 'method' in body
186
+ ? String((body as { method: unknown }).method)
187
+ : null;
188
+
141
189
  this._invocations.push({
142
190
  method: req.method ?? 'GET',
143
191
  path: url,
192
+ rpcMethod,
144
193
  body,
145
194
  timestamp: Date.now(),
146
195
  });
147
196
 
148
- // GET /agent.json
149
- if (req.method === 'GET' && url.startsWith('/agent.json')) {
197
+ // GET /.well-known/agent-card.json — A2A v0.3 well-known path
198
+ if (req.method === 'GET' && url.startsWith('/.well-known/agent-card.json')) {
150
199
  const card = {
151
200
  protocolVersion: '0.3.0',
152
201
  name: 'openwop-conformance-fake-a2a',
153
202
  description: 'Synthetic A2A peer for openwop conformance suite',
154
- url: this.endpoint(),
203
+ url: `${this.endpoint()}/a2a/jsonrpc`,
155
204
  version: '1.0.0',
156
- capabilities: { streaming: false },
205
+ capabilities: { streaming: false, pushNotifications: false },
157
206
  skills: [
158
207
  {
159
208
  id: 'echo',
160
209
  name: 'echo',
161
210
  description: 'Returns input verbatim',
211
+ tags: ['echo'],
162
212
  },
163
213
  ],
214
+ defaultInputModes: ['text'],
215
+ defaultOutputModes: ['text'],
216
+ additionalInterfaces: [
217
+ { url: `${this.endpoint()}/a2a/jsonrpc`, transport: 'JSONRPC' },
218
+ ],
164
219
  };
165
220
  res.writeHead(200, { 'Content-Type': 'application/json' });
166
221
  res.end(JSON.stringify(card));
167
222
  return;
168
223
  }
169
224
 
170
- // POST /tasks create task
171
- if (req.method === 'POST' && url === '/tasks') {
172
- const taskId = `task-${++this._taskIdCounter}`;
173
- const initial: A2ATaskState = this._nextStateOverride ?? 'SUBMITTED';
174
- this._nextStateOverride = null;
175
- const task: A2ATask = {
176
- taskId,
177
- state: initial,
178
- messages: body && typeof body === 'object' ? [{ role: 'user', content: body }] : [],
179
- };
180
- this._tasks.set(taskId, task);
181
- res.writeHead(200, { 'Content-Type': 'application/json' });
182
- res.end(JSON.stringify({ taskId, state: task.state }));
183
- return;
184
- }
225
+ // POST /a2a/jsonrpc A2A v0.3 JSON-RPC transport
226
+ if (req.method === 'POST' && url === '/a2a/jsonrpc') {
227
+ if (
228
+ !body ||
229
+ typeof body !== 'object' ||
230
+ (body as { jsonrpc?: unknown }).jsonrpc !== '2.0'
231
+ ) {
232
+ res.writeHead(200, { 'Content-Type': 'application/json' });
233
+ res.end(
234
+ JSON.stringify({
235
+ jsonrpc: '2.0',
236
+ id: null,
237
+ error: { code: -32600, message: 'Invalid JSON-RPC envelope' },
238
+ }),
239
+ );
240
+ return;
241
+ }
242
+ const rpc = body as { id?: string | number; method?: string; params?: unknown };
185
243
 
186
- // GET /tasks/{taskId}
187
- const getMatch = url.match(/^\/tasks\/([^/?]+)$/);
188
- if (req.method === 'GET' && getMatch) {
189
- const task = this._tasks.get(decodeURIComponent(getMatch[1]));
190
- if (!task) {
191
- res.writeHead(404, { 'Content-Type': 'application/json' });
192
- res.end(JSON.stringify({ error: 'not_found' }));
244
+ if (rpc.method === 'message/send') {
245
+ const taskId = `task-${++this._taskIdCounter}`;
246
+ const contextId = `ctx-${taskId}`;
247
+ const initial: A2ATaskState = this._nextStateOverride ?? 'SUBMITTED';
248
+ this._nextStateOverride = null;
249
+ const userMessage = (rpc.params as { message?: { parts?: unknown[] } } | undefined)
250
+ ?.message;
251
+ const task: A2ATask = {
252
+ id: taskId,
253
+ contextId,
254
+ state: initial,
255
+ history: userMessage
256
+ ? [{ role: 'user', parts: userMessage.parts ?? [] }]
257
+ : [],
258
+ };
259
+ this._tasks.set(taskId, task);
260
+ res.writeHead(200, { 'Content-Type': 'application/json' });
261
+ res.end(
262
+ JSON.stringify({
263
+ jsonrpc: '2.0',
264
+ id: rpc.id ?? null,
265
+ result: this._taskToWire(task),
266
+ }),
267
+ );
193
268
  return;
194
269
  }
195
- res.writeHead(200, { 'Content-Type': 'application/json' });
196
- res.end(JSON.stringify(task));
197
- return;
198
- }
199
270
 
200
- // POST /tasks/{taskId}/messages — host resumes an INPUT_REQUIRED task
201
- const msgMatch = url.match(/^\/tasks\/([^/?]+)\/messages$/);
202
- if (req.method === 'POST' && msgMatch) {
203
- const task = this._tasks.get(decodeURIComponent(msgMatch[1]));
204
- if (!task) {
205
- res.writeHead(404, { 'Content-Type': 'application/json' });
206
- res.end(JSON.stringify({ error: 'not_found' }));
271
+ if (rpc.method === 'tasks/get') {
272
+ const params = (rpc.params ?? {}) as { id?: string };
273
+ const task = params.id ? this._tasks.get(params.id) : undefined;
274
+ if (!task) {
275
+ res.writeHead(200, { 'Content-Type': 'application/json' });
276
+ res.end(
277
+ JSON.stringify({
278
+ jsonrpc: '2.0',
279
+ id: rpc.id ?? null,
280
+ error: { code: -32001, message: 'Task not found' },
281
+ }),
282
+ );
283
+ return;
284
+ }
285
+ res.writeHead(200, { 'Content-Type': 'application/json' });
286
+ res.end(
287
+ JSON.stringify({
288
+ jsonrpc: '2.0',
289
+ id: rpc.id ?? null,
290
+ result: this._taskToWire(task),
291
+ }),
292
+ );
207
293
  return;
208
294
  }
209
- task.messages.push({ role: 'user', content: body });
210
- // Move from INPUT_REQUIRED back to WORKING then to COMPLETED for the
211
- // simple roundtrip. Tests that need a different next-state set it
212
- // via setNextState() before posting the message.
213
- task.state = this._nextStateOverride ?? 'COMPLETED';
214
- this._nextStateOverride = null;
295
+
215
296
  res.writeHead(200, { 'Content-Type': 'application/json' });
216
- res.end(JSON.stringify({ taskId: task.taskId, state: task.state }));
297
+ res.end(
298
+ JSON.stringify({
299
+ jsonrpc: '2.0',
300
+ id: rpc.id ?? null,
301
+ error: { code: -32601, message: `Method not found: ${rpc.method}` },
302
+ }),
303
+ );
217
304
  return;
218
305
  }
219
306
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Behavior-gate helper for capability-gated conformance scenarios.
3
+ *
4
+ * Some scenarios in `conformance/src/scenarios/` validate optional profiles
5
+ * — audit-log integrity, rate-limit envelope, multi-region idempotency,
6
+ * `configurableSchema`, webhook signature versioning, pause/resume, etc.
7
+ * When a host doesn't advertise the profile, those scenarios have two
8
+ * defensible modes:
9
+ *
10
+ * - **Default (skip):** log a warning and return early. The suite still
11
+ * passes overall, reflecting what the host has implemented. This is
12
+ * what `@openwop/openwop-conformance` runs by default so a v1.0-only
13
+ * host doesn't suddenly fail the suite when new optional profiles ship.
14
+ *
15
+ * - **Behavior-required (fail):** set `OPENWOP_REQUIRE_BEHAVIOR=true` to
16
+ * turn missing advertisements into hard failures. A "passing" run with
17
+ * this flag means the host advertises every optional profile AND every
18
+ * scenario exercises real behavior — useful for hosts that want to
19
+ * claim full coverage in `INTEROP-MATRIX.md`.
20
+ *
21
+ * - **Strict-mode opt-out (skip in strict mode too):** set
22
+ * `OPENWOP_OPTED_OUT_PROFILES=name1,name2,...` to declare that the
23
+ * host operator has deliberately chosen NOT to implement those
24
+ * profiles. In strict mode the gate skips them with a "honest
25
+ * opt-out" log line instead of failing — minimal hosts that
26
+ * advertise only what they implement can still go strict-mode
27
+ * green without falsifying capability claims. Conflict check:
28
+ * if a profile appears in BOTH `OPENWOP_OPTED_OUT_PROFILES` AND
29
+ * the host's discovery `capabilities.auth.profiles[]` (or
30
+ * equivalent), the gate logs a loud warning — opt-outs and
31
+ * advertisements are mutually exclusive.
32
+ *
33
+ * Usage:
34
+ *
35
+ * ```ts
36
+ * import { behaviorGate } from '../lib/behavior-gate.js';
37
+ *
38
+ * it('host that claims the profile advertises the right fields', async () => {
39
+ * const advertised = await isProfileAdvertised();
40
+ * if (!behaviorGate('openwop-audit-log-integrity', advertised)) {
41
+ * return; // skipped in default mode; FAIL'd in strict mode
42
+ * }
43
+ *
44
+ * // ... assertions ...
45
+ * });
46
+ * ```
47
+ *
48
+ * In strict mode, `behaviorGate` throws an assertion error with a citation
49
+ * to the relevant spec section so the failure message is self-explanatory.
50
+ */
51
+
52
+ import { expect } from 'vitest';
53
+ import { loadEnv } from './env.js';
54
+
55
+ /**
56
+ * Returns true if the scenario should proceed with assertions (advertised),
57
+ * false if the scenario should `return` early (default-mode skip OR
58
+ * strict-mode honest opt-out). In strict mode (`OPENWOP_REQUIRE_BEHAVIOR=true`)
59
+ * with `profileName` NOT in `OPENWOP_OPTED_OUT_PROFILES`, throws — so the
60
+ * caller never receives `false` in that combination.
61
+ *
62
+ * If the host BOTH advertises the profile AND the operator listed it in
63
+ * the opt-out env var, surface a warning (likely typo) and treat as
64
+ * advertised (proceed). Advertisement always wins over opt-out: opting
65
+ * out of a profile you actually implement is meaningless.
66
+ */
67
+ export function behaviorGate(profileName: string, advertised: boolean): boolean {
68
+ const env = loadEnv();
69
+ const optedOut = env.optedOutProfiles.has(profileName);
70
+
71
+ if (advertised && optedOut) {
72
+ // eslint-disable-next-line no-console
73
+ console.warn(
74
+ `[${profileName}] both ADVERTISED by the host AND listed in OPENWOP_OPTED_OUT_PROFILES — ` +
75
+ `opt-out is ignored. Remove from the env var to clear this warning.`,
76
+ );
77
+ }
78
+
79
+ if (advertised) return true;
80
+
81
+ if (optedOut) {
82
+ // Honest opt-out: the operator declared the host does not implement
83
+ // this profile. Skip in BOTH default and strict mode.
84
+ // eslint-disable-next-line no-console
85
+ console.warn(
86
+ `[${profileName}] honest opt-out (OPENWOP_OPTED_OUT_PROFILES); skipping`,
87
+ );
88
+ return false;
89
+ }
90
+
91
+ if (env.requireBehavior) {
92
+ expect(
93
+ advertised,
94
+ `OPENWOP_REQUIRE_BEHAVIOR=true: host MUST advertise the ${profileName} profile for this scenario to run, ` +
95
+ `or declare opt-out via OPENWOP_OPTED_OUT_PROFILES=${profileName}. ` +
96
+ `See conformance/coverage.md §"Capability-gated scenarios".`,
97
+ ).toBe(true);
98
+ // expect.toBe(true) throws; we won't reach here.
99
+ }
100
+
101
+ // Default-mode soft-skip.
102
+ // eslint-disable-next-line no-console
103
+ console.warn(
104
+ `[${profileName}] profile not advertised; skipping (set OPENWOP_REQUIRE_BEHAVIOR=true to fail)`,
105
+ );
106
+ return false;
107
+ }
package/src/lib/env.ts CHANGED
@@ -8,6 +8,23 @@
8
8
  * Optional (cosmetic — surfaced in failure messages):
9
9
  * OPENWOP_IMPLEMENTATION_NAME — e.g., "acme-openwop-server"
10
10
  * OPENWOP_IMPLEMENTATION_VERSION — e.g., "1.0"
11
+ *
12
+ * Optional (behavior-gate strictness):
13
+ * OPENWOP_REQUIRE_BEHAVIOR=true — capability-gated scenarios (audit-log
14
+ * integrity, rate-limit envelope, multi-region idempotency, etc.) FAIL
15
+ * instead of skipping when the host doesn't advertise the profile.
16
+ * Default is false — scenarios skip with a warning so default conformance
17
+ * runs cover what the host has implemented. See `lib/behavior-gate.ts`
18
+ * and `conformance/coverage.md` §"Capability-gated scenarios".
19
+ *
20
+ * OPENWOP_OPTED_OUT_PROFILES — comma-separated profile names the host
21
+ * operator has DELIBERATELY chosen not to implement. In strict mode
22
+ * these scenarios skip (logged as "honest opt-out") rather than
23
+ * failing — distinguishes "host doesn't claim this surface" (good)
24
+ * from "host claims but doesn't deliver" (bug). Lets honest minimal
25
+ * hosts go strict-mode green without falsifying capability claims.
26
+ * Example for SQLite:
27
+ * OPENWOP_OPTED_OUT_PROFILES=openwop-production,openwop-auth-mtls
11
28
  */
12
29
 
13
30
  export interface ConformanceEnv {
@@ -15,6 +32,16 @@ export interface ConformanceEnv {
15
32
  readonly apiKey: string;
16
33
  readonly implementationName: string;
17
34
  readonly implementationVersion: string;
35
+ readonly requireBehavior: boolean;
36
+ /**
37
+ * Profiles the host operator has declared the host does NOT claim. Set
38
+ * via `OPENWOP_OPTED_OUT_PROFILES=name1,name2`. In strict mode, the
39
+ * behavior-gate honors this set as PASS-by-opt-out rather than failing
40
+ * the scenario. Never include a profile the host actually advertises —
41
+ * that's a typo, not an opt-out, and `behaviorGate` will surface a
42
+ * warning if it detects the conflict.
43
+ */
44
+ readonly optedOutProfiles: ReadonlySet<string>;
18
45
  }
19
46
 
20
47
  let cached: ConformanceEnv | null = null;
@@ -39,11 +66,21 @@ export function loadEnv(): ConformanceEnv {
39
66
  // Strip trailing slash so URL composition is consistent.
40
67
  const normalizedBase = baseUrl.replace(/\/$/, '');
41
68
 
69
+ const optedOutRaw = process.env.OPENWOP_OPTED_OUT_PROFILES?.trim() ?? '';
70
+ const optedOutProfiles = new Set(
71
+ optedOutRaw
72
+ .split(',')
73
+ .map((s) => s.trim())
74
+ .filter((s) => s.length > 0),
75
+ );
76
+
42
77
  cached = {
43
78
  baseUrl: normalizedBase,
44
79
  apiKey,
45
80
  implementationName: process.env.OPENWOP_IMPLEMENTATION_NAME?.trim() ?? 'unknown',
46
81
  implementationVersion: process.env.OPENWOP_IMPLEMENTATION_VERSION?.trim() ?? 'unknown',
82
+ requireBehavior: process.env.OPENWOP_REQUIRE_BEHAVIOR === 'true',
83
+ optedOutProfiles,
47
84
  };
48
85
  return cached;
49
86
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Unit tests for `grpc-framing.ts` — gRPC HTTP/2 message framing.
3
+ *
4
+ * @see grpc-framing.ts
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { frameMessage, unframeMessages } from './grpc-framing.js';
9
+
10
+ describe('grpc-framing: frameMessage', () => {
11
+ it('prepends a 5-byte header to a single payload', () => {
12
+ const payload = new Uint8Array([0xab, 0xcd, 0xef]);
13
+ const framed = frameMessage(payload);
14
+ expect(framed.byteLength).toBe(8);
15
+ expect(framed[0]).toBe(0); // identity compression
16
+ expect(framed[1]).toBe(0);
17
+ expect(framed[2]).toBe(0);
18
+ expect(framed[3]).toBe(0);
19
+ expect(framed[4]).toBe(3); // length = 3
20
+ expect(framed[5]).toBe(0xab);
21
+ expect(framed[6]).toBe(0xcd);
22
+ expect(framed[7]).toBe(0xef);
23
+ });
24
+
25
+ it('frames a zero-length payload as a 5-byte header alone', () => {
26
+ const framed = frameMessage(new Uint8Array(0));
27
+ expect(framed.byteLength).toBe(5);
28
+ expect(framed[0]).toBe(0);
29
+ expect(framed[4]).toBe(0);
30
+ });
31
+
32
+ it('encodes lengths > 256 in big-endian order', () => {
33
+ const payload = new Uint8Array(300);
34
+ const framed = frameMessage(payload);
35
+ // length = 300 = 0x0000012C, big-endian: 00 00 01 2C
36
+ expect(framed[1]).toBe(0);
37
+ expect(framed[2]).toBe(0);
38
+ expect(framed[3]).toBe(1);
39
+ expect(framed[4]).toBe(0x2c);
40
+ });
41
+ });
42
+
43
+ describe('grpc-framing: unframeMessages', () => {
44
+ it('parses a single frame', () => {
45
+ const payload = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
46
+ const framed = frameMessage(payload);
47
+ const messages = unframeMessages(framed);
48
+ expect(messages.length).toBe(1);
49
+ expect(Array.from(messages[0]!)).toEqual([0x01, 0x02, 0x03, 0x04]);
50
+ });
51
+
52
+ it('parses multiple concatenated frames', () => {
53
+ const a = frameMessage(new Uint8Array([0xaa, 0xab]));
54
+ const b = frameMessage(new Uint8Array([0xbb]));
55
+ const c = frameMessage(new Uint8Array([0xcc, 0xcd, 0xce, 0xcf]));
56
+ const combined = new Uint8Array(a.byteLength + b.byteLength + c.byteLength);
57
+ combined.set(a, 0);
58
+ combined.set(b, a.byteLength);
59
+ combined.set(c, a.byteLength + b.byteLength);
60
+ const messages = unframeMessages(combined);
61
+ expect(messages.length).toBe(3);
62
+ expect(Array.from(messages[0]!)).toEqual([0xaa, 0xab]);
63
+ expect(Array.from(messages[1]!)).toEqual([0xbb]);
64
+ expect(Array.from(messages[2]!)).toEqual([0xcc, 0xcd, 0xce, 0xcf]);
65
+ });
66
+
67
+ it('parses an empty buffer as zero frames', () => {
68
+ expect(unframeMessages(new Uint8Array(0))).toEqual([]);
69
+ });
70
+
71
+ it('throws on truncated header', () => {
72
+ // Only 3 bytes of a 5-byte header.
73
+ const buf = new Uint8Array([0, 0, 0]);
74
+ expect(() => unframeMessages(buf)).toThrow(/frame truncated/i);
75
+ });
76
+
77
+ it('throws on truncated payload', () => {
78
+ // 5-byte header declares 10 bytes of payload but only 4 follow.
79
+ const buf = new Uint8Array([0, 0, 0, 0, 10, 1, 2, 3, 4]);
80
+ expect(() => unframeMessages(buf)).toThrow(/payload truncated/i);
81
+ });
82
+
83
+ it('throws on unsupported compression flag', () => {
84
+ // Flag = 1 → compression negotiated, which we don't implement.
85
+ const buf = new Uint8Array([1, 0, 0, 0, 0]);
86
+ expect(() => unframeMessages(buf)).toThrow(/compression flag/i);
87
+ });
88
+
89
+ it('round-trips: frame → unframe yields original payload', () => {
90
+ const original = new Uint8Array([0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f]); // protobuf "hello"
91
+ const framed = frameMessage(original);
92
+ const unframed = unframeMessages(framed);
93
+ expect(unframed.length).toBe(1);
94
+ expect(Array.from(unframed[0]!)).toEqual(Array.from(original));
95
+ });
96
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * gRPC HTTP/2 message framing primitive — Track 11 OTLP/gRPC.
3
+ *
4
+ * gRPC over HTTP/2 wraps each protobuf message in a 5-byte frame
5
+ * prefix:
6
+ *
7
+ * ┌──────────┬───────────────────────┬─────────────────────┐
8
+ * │ 1 byte │ 4 bytes │ N bytes │
9
+ * │ flags │ length (big-endian) │ payload │
10
+ * └──────────┴───────────────────────┴─────────────────────┘
11
+ *
12
+ * `flags` is `0x00` for uncompressed (the only mode we implement).
13
+ * `0x01` indicates a per-message compression scheme negotiated via
14
+ * the `grpc-encoding` header; we reject this since the conformance
15
+ * collector advertises identity encoding only.
16
+ *
17
+ * Multiple frames MAY be concatenated in a stream; for OTLP Export
18
+ * unary calls the request and response both carry exactly one frame.
19
+ *
20
+ * Pure stdlib — no `@grpc/grpc-js` dependency. Matches the
21
+ * zero-new-deps stance of `otlp-protobuf.ts` (the hand-rolled
22
+ * decoder for the OTLP message bodies).
23
+ *
24
+ * @see https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
25
+ */
26
+
27
+ /** Length-prefix a single protobuf payload with the gRPC 5-byte frame. */
28
+ export function frameMessage(payload: Uint8Array): Uint8Array {
29
+ const out = new Uint8Array(5 + payload.byteLength);
30
+ out[0] = 0; // compression flag — identity
31
+ // Big-endian 32-bit length
32
+ out[1] = (payload.byteLength >>> 24) & 0xff;
33
+ out[2] = (payload.byteLength >>> 16) & 0xff;
34
+ out[3] = (payload.byteLength >>> 8) & 0xff;
35
+ out[4] = payload.byteLength & 0xff;
36
+ out.set(payload, 5);
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Parse one or more gRPC-framed messages from a concatenated buffer.
42
+ * Throws if any frame uses a compression scheme we don't implement,
43
+ * or if the buffer truncates mid-frame.
44
+ */
45
+ export function unframeMessages(buf: Uint8Array): Uint8Array[] {
46
+ const out: Uint8Array[] = [];
47
+ let i = 0;
48
+ while (i < buf.byteLength) {
49
+ if (i + 5 > buf.byteLength) {
50
+ throw new Error(
51
+ `gRPC frame truncated: need 5-byte header at offset ${i}, have ${buf.byteLength - i}`,
52
+ );
53
+ }
54
+ const flag = buf[i]!;
55
+ if (flag !== 0) {
56
+ throw new Error(
57
+ `gRPC frame at offset ${i} has compression flag ${flag}; only identity (0) is supported`,
58
+ );
59
+ }
60
+ const len =
61
+ ((buf[i + 1]! << 24) >>> 0) |
62
+ ((buf[i + 2]! << 16) >>> 0) |
63
+ ((buf[i + 3]! << 8) >>> 0) |
64
+ buf[i + 4]!;
65
+ const payloadStart = i + 5;
66
+ const payloadEnd = payloadStart + len;
67
+ if (payloadEnd > buf.byteLength) {
68
+ throw new Error(
69
+ `gRPC frame payload truncated: declared length ${len} at offset ${i + 1}, but only ${buf.byteLength - payloadStart} bytes remain`,
70
+ );
71
+ }
72
+ out.push(buf.subarray(payloadStart, payloadEnd));
73
+ i = payloadEnd;
74
+ }
75
+ return out;
76
+ }