@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.
- package/CHANGELOG.md +17 -0
- package/README.md +31 -6
- package/api/grpc/openwop.proto +251 -0
- package/api/openapi.yaml +109 -3
- package/coverage.md +48 -9
- package/fixtures/conformance-configurable-schema.json +39 -0
- package/fixtures/conformance-subworkflow-parent.json +1 -1
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
- package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
- package/fixtures.md +21 -0
- package/package.json +3 -1
- package/schemas/README.md +4 -0
- package/schemas/audit-verify-result.schema.json +90 -0
- package/schemas/capabilities.schema.json +342 -1
- package/schemas/node-pack-manifest.schema.json +4 -4
- package/schemas/pack-lockfile.schema.json +92 -0
- package/schemas/registry-version-manifest.schema.json +145 -0
- package/schemas/run-event-payloads.schema.json +20 -4
- package/schemas/run-event.schema.json +2 -1
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +107 -0
- package/src/lib/env.ts +37 -0
- package/src/lib/grpc-framing.test.ts +96 -0
- package/src/lib/grpc-framing.ts +76 -0
- package/src/lib/oidc-issuer.test.ts +328 -0
- package/src/lib/oidc-issuer.ts +241 -0
- package/src/lib/otel-collector-grpc.test.ts +191 -0
- package/src/lib/otel-collector.test.ts +303 -0
- package/src/lib/otel-collector.ts +318 -14
- package/src/lib/otlp-protobuf.test.ts +461 -0
- package/src/lib/otlp-protobuf.ts +529 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
- package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
- package/src/scenarios/agentMessageReducer.test.ts +1 -0
- package/src/scenarios/agentMetadata.test.ts +1 -0
- package/src/scenarios/agentPackExport.test.ts +1 -0
- package/src/scenarios/agentPackInstall.test.ts +1 -0
- package/src/scenarios/agentPackProvenance.test.ts +1 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -6
- package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
- package/src/scenarios/auth-mtls.test.ts +274 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
- package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
- package/src/scenarios/bulk-cancel.test.ts +111 -0
- package/src/scenarios/configurable-schema.test.ts +48 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
- package/src/scenarios/conversationLifecycle.test.ts +1 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
- package/src/scenarios/discovery.test.ts +183 -0
- package/src/scenarios/http-client-ssrf.test.ts +71 -0
- package/src/scenarios/idempotency.test.ts +6 -0
- package/src/scenarios/idempotencyRetry.test.ts +3 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- package/src/scenarios/multi-region-idempotency.test.ts +39 -4
- package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
- package/src/scenarios/orchestratorDispatch.test.ts +1 -0
- package/src/scenarios/orchestratorTermination.test.ts +1 -0
- package/src/scenarios/otel-emission-grpc.test.ts +98 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
- package/src/scenarios/pause-resume.test.ts +119 -0
- package/src/scenarios/production-backpressure.test.ts +342 -0
- package/src/scenarios/production-retention-expiry.test.ts +164 -0
- package/src/scenarios/registry-public.test.ts +222 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
- package/src/scenarios/replay-retention-expiry.test.ts +178 -0
- package/src/scenarios/restart-during-run.test.ts +177 -0
- package/src/scenarios/spec-corpus-validity.test.ts +59 -26
- package/src/scenarios/staleClaim.test.ts +3 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
- package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
- package/src/scenarios/webhook-negative.test.ts +90 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
- package/src/setup.ts +25 -1
- package/vitest.config.ts +5 -1
package/src/lib/a2a-fake-peer.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
12
|
-
* GET /agent.json
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* POST /
|
|
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/
|
|
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
|
-
|
|
61
|
+
id: string;
|
|
62
|
+
contextId: string;
|
|
42
63
|
state: A2ATaskState;
|
|
43
|
-
|
|
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) =>
|
|
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 /
|
|
171
|
-
if (req.method === 'POST' && url === '/
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|