@openwop/openwop-conformance 1.72.2 → 1.98.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.
- package/CHANGELOG.md +36 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +58 -0
- package/api/openapi.yaml +4 -1
- package/dist/cli.js +107 -1
- package/dist/lib/profiles.js +70 -4
- package/package.json +2 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/README.md +2 -0
- package/schemas/capabilities.schema.json +2054 -572
- package/schemas/certification-bundle-v2.schema.json +108 -0
- package/schemas/run-event-payloads.schema.json +3411 -857
- package/schemas/run-event.schema.json +31 -9
- package/schemas/workflow-definition.schema.json +492 -130
- package/schemas/workload-identity.schema.json +73 -0
- package/src/cli.ts +119 -1
- package/src/lib/a2a-fake-peer.ts +20 -0
- package/src/lib/behavior-gate.ts +42 -7
- package/src/lib/llm-cache-key-recipe.ts +51 -0
- package/src/lib/mcp-fake-server.ts +20 -0
- package/src/lib/profiles.ts +95 -4
- package/src/lib/requirement-ledger.ts +138 -0
- package/src/lib/requirement-registry.ts +62 -0
- package/src/scenarios/a2a-version-negotiation.test.ts +159 -0
- package/src/scenarios/capability-example-root-layout.test.ts +113 -0
- package/src/scenarios/certification-bundle-v2.test.ts +157 -0
- package/src/scenarios/certification-floor-enforcement.test.ts +115 -0
- package/src/scenarios/compensation-behavior.test.ts +164 -0
- package/src/scenarios/compensation-profile.test.ts +175 -0
- package/src/scenarios/contract-provenance.test.ts +209 -0
- package/src/scenarios/core-manifest-and-extension-registry.test.ts +198 -0
- package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +219 -0
- package/src/scenarios/effect-identity-composition.test.ts +129 -0
- package/src/scenarios/effect-identity-cross-scope.test.ts +82 -0
- package/src/scenarios/mcp-version-negotiation.test.ts +159 -0
- package/src/scenarios/multi-region-effect-vocabulary.test.ts +175 -0
- package/src/scenarios/multi-region-idempotency.test.ts +17 -7
- package/src/scenarios/openapi-resolved-paths.test.ts +127 -0
- package/src/scenarios/protocol-version-grammar.test.ts +119 -0
- package/src/scenarios/requirement-ledger.test.ts +162 -0
- package/src/scenarios/rfc-0147-self-audit.test.ts +104 -0
- package/src/scenarios/rfc-lifecycle-coherence.test.ts +137 -0
- package/src/scenarios/semantic-digest-v2.test.ts +128 -0
- package/src/scenarios/semantic-digest-vectors.test.ts +140 -0
- package/src/scenarios/spec-corpus-validity.test.ts +22 -8
- package/src/scenarios/strict-behavior-gate.test.ts +120 -0
- package/src/scenarios/versioned-composition-profiles.test.ts +183 -0
- package/src/scenarios/workload-identity-behavior.test.ts +188 -0
- package/src/scenarios/workload-identity-profile.test.ts +175 -0
- package/vectors/semantic-request-digest-v2.json +236 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/workload-identity.schema.json",
|
|
4
|
+
"title": "OpenWOP workload identity and delegated actor chain (RFC 0154)",
|
|
5
|
+
"description": "RFC 0154 §A/§B. An authenticated workload identity, and the delegation context a request may carry.\n\nThe governing rule is negative and it is why every object here is closed: raw certificates, tokens, proofs, and credentials MUST NOT enter these objects. `subject` is an OPAQUE identifier and `proofRef` is a REFERENCE. A verified identity is a fact about a completed verification, not a container for the material that proved it — and these objects are projected into events, spans, and audit records, which is exactly where credential material must never reach (`SECURITY/threat-model-secret-leakage.md`).\n\nThe second rule is that identity is not authorization (RFC 0147 R12). Knowing which workload called says nothing about what it may do; tenant, audience, and policy are re-evaluated at every boundary.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["scheme", "subject"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"scheme": {
|
|
11
|
+
"enum": ["spiffe", "mtls-san", "cloud-subject", "oauth-client"],
|
|
12
|
+
"description": "How the identity was presented and verified. Closed: an unrecognized scheme is a verification path nobody implemented."
|
|
13
|
+
},
|
|
14
|
+
"subject": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"minLength": 1,
|
|
17
|
+
"description": "Opaque verified identifier, e.g. a SPIFFE ID. MUST NOT be a credential, token, or certificate."
|
|
18
|
+
},
|
|
19
|
+
"issuer": { "type": "string", "minLength": 1 },
|
|
20
|
+
"audience": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1,
|
|
23
|
+
"description": "RFC 0154 §A — the audience the presented identity was minted for. A host MUST reject an identity whose audience is not its own: accepting one is how a credential valid elsewhere becomes a credential valid here."
|
|
24
|
+
},
|
|
25
|
+
"keyBinding": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"description": "Proof-of-possession binding. Carries the METHOD and a thumbprint reference, never the key.",
|
|
29
|
+
"required": ["method"],
|
|
30
|
+
"properties": {
|
|
31
|
+
"method": { "enum": ["mtls", "dpop"] },
|
|
32
|
+
"thumbprintRef": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"pattern": "^sha256:[0-9a-f]{64}$",
|
|
35
|
+
"description": "Digest reference to the bound key. Pattern-constrained so a raw key cannot be pasted here and still validate."
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"delegation": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": false,
|
|
42
|
+
"description": "RFC 0154 §B — the verified delegation chain, if any.",
|
|
43
|
+
"required": ["chain", "audience"],
|
|
44
|
+
"properties": {
|
|
45
|
+
"chain": {
|
|
46
|
+
"type": "array",
|
|
47
|
+
"minItems": 1,
|
|
48
|
+
"description": "Ordered delegation hops. Non-empty: a delegation context with no chain claims a delegation nobody can inspect.",
|
|
49
|
+
"items": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"additionalProperties": false,
|
|
52
|
+
"required": ["subject"],
|
|
53
|
+
"properties": {
|
|
54
|
+
"subject": { "type": "string", "minLength": 1 },
|
|
55
|
+
"issuer": { "type": "string", "minLength": 1 }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"audience": { "type": "string", "minLength": 1 },
|
|
60
|
+
"expiresAt": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"format": "date-time",
|
|
63
|
+
"description": "RFC 0154 §B — a delegation without an expiry is a standing grant, which is not what delegation means."
|
|
64
|
+
},
|
|
65
|
+
"proofRef": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"pattern": "^sha256:[0-9a-f]{64}$",
|
|
68
|
+
"description": "Digest REFERENCE to the verified proof. Never the proof itself."
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -50,6 +50,7 @@ interface ParsedArgs {
|
|
|
50
50
|
readonly implVersion: string | undefined;
|
|
51
51
|
/** RFC 0089 — emit a conformance certification bundle to this path. */
|
|
52
52
|
readonly certify: string | undefined;
|
|
53
|
+
readonly bundleVersion: '1' | '2';
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
@@ -61,6 +62,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
61
62
|
let impl: string | undefined;
|
|
62
63
|
let implVersion: string | undefined;
|
|
63
64
|
let certify: string | undefined;
|
|
65
|
+
let bundleVersion: '1' | '2' = '1';
|
|
64
66
|
|
|
65
67
|
for (let i = 0; i < argv.length; i++) {
|
|
66
68
|
const arg = argv[i] ?? '';
|
|
@@ -103,6 +105,15 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
103
105
|
case '--implementation-version':
|
|
104
106
|
implVersion = nextValue();
|
|
105
107
|
break;
|
|
108
|
+
case '--bundle-version': {
|
|
109
|
+
const v = nextValue();
|
|
110
|
+
if (v !== '1' && v !== '2') {
|
|
111
|
+
process.stderr.write(`--bundle-version must be 1 or 2 (got '${v}')\n`);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
bundleVersion = v;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
106
117
|
case '--certify':
|
|
107
118
|
certify = nextValue();
|
|
108
119
|
break;
|
|
@@ -113,7 +124,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
113
124
|
}
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
return { baseUrl, apiKey, offline, filter, help, impl, implVersion, certify };
|
|
127
|
+
return { baseUrl, apiKey, offline, filter, help, impl, implVersion, certify, bundleVersion };
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
const HELP_TEXT = `openwop-conformance — run the openwop conformance suite against a server
|
|
@@ -134,6 +145,10 @@ Implementation labels (cosmetic — surface in failure messages):
|
|
|
134
145
|
--impl-version <version> Implementation version (env: OPENWOP_IMPLEMENTATION_VERSION)
|
|
135
146
|
|
|
136
147
|
Certification (RFC 0089):
|
|
148
|
+
--bundle-version <1|2> Certification bundle format. Default 1. Version 2 (RFC 0148
|
|
149
|
+
§C) records per-requirement DISPOSITIONS instead of pass/fail/skip
|
|
150
|
+
file lists, so "we could not check" stops being indistinguishable
|
|
151
|
+
from "checked and it holds". See the note it prints.
|
|
137
152
|
--certify <out.json> Generate a machine-readable conformance certification
|
|
138
153
|
bundle: fetch /.well-known/openwop (captured verbatim +
|
|
139
154
|
SHA-256), derive claimedProfiles from it, run the suite
|
|
@@ -348,6 +363,109 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
348
363
|
},
|
|
349
364
|
};
|
|
350
365
|
|
|
366
|
+
// (d2) RFC 0148 §C — bundle v2.
|
|
367
|
+
//
|
|
368
|
+
// v1's `{passed, failed, skipped}` file lists cannot express the distinction
|
|
369
|
+
// this program exists for. A file counted as `passed` whether its assertions
|
|
370
|
+
// ran or its runner returned early, and `skipped` flattened three different
|
|
371
|
+
// claims — operator-excluded, not-applicable, and could-not-check — of which
|
|
372
|
+
// only the first two are certifiable.
|
|
373
|
+
//
|
|
374
|
+
// THE HONEST LIMIT, stated because it changes what a v2 bundle from this
|
|
375
|
+
// runner means: the runner reads vitest's per-FILE outcome. It does not read
|
|
376
|
+
// the RFC 0148 §A requirement ledger, because scenarios do not yet record
|
|
377
|
+
// into it. So a skipped file cannot be split into `skipped` /`inapplicable` /
|
|
378
|
+
// `blocked` here, and §A's rule is that an unclassifiable requirement resolves
|
|
379
|
+
// to `blocked` — never to a pass. That is what this emits.
|
|
380
|
+
//
|
|
381
|
+
// The consequence is deliberate and correct: a v2 bundle produced today shows
|
|
382
|
+
// a large `blocked` count and therefore does NOT certify. That is not a defect
|
|
383
|
+
// in the emitter. It is the true state of the evidence, which v1 was unable to
|
|
384
|
+
// represent and therefore reported as a clean skip.
|
|
385
|
+
if (args.bundleVersion === '2') {
|
|
386
|
+
const scenarioIds = [...passed, ...failed, ...skipped].sort();
|
|
387
|
+
const manifestSha = createHash('sha256').update(scenarioIds.join('\n'), 'utf8').digest('hex');
|
|
388
|
+
// Configuration identity per RFC 0147 §A.4: what the run was pointed at, and
|
|
389
|
+
// the discovery it saw. Two runs against differently-configured hosts are
|
|
390
|
+
// different evidence, and this is what says so.
|
|
391
|
+
const configSha = createHash('sha256')
|
|
392
|
+
.update(`${args.baseUrl}\n${sha256}\n${process.env['OPENWOP_REQUIRE_BEHAVIOR'] ?? ''}`, 'utf8')
|
|
393
|
+
.digest('hex');
|
|
394
|
+
|
|
395
|
+
const requirements = [
|
|
396
|
+
...passed.map((f) => ({
|
|
397
|
+
requirementId: `openwop.scenario.${f.replace(/\.test\.ts$/, '')}`,
|
|
398
|
+
scenarioId: f,
|
|
399
|
+
disposition: 'executed-pass' as const,
|
|
400
|
+
})),
|
|
401
|
+
...failed.map((f) => ({
|
|
402
|
+
requirementId: `openwop.scenario.${f.replace(/\.test\.ts$/, '')}`,
|
|
403
|
+
scenarioId: f,
|
|
404
|
+
disposition: 'executed-fail' as const,
|
|
405
|
+
detail: 'the scenario executed and failed',
|
|
406
|
+
})),
|
|
407
|
+
...skipped.map((f) => ({
|
|
408
|
+
requirementId: `openwop.scenario.${f.replace(/\.test\.ts$/, '')}`,
|
|
409
|
+
scenarioId: f,
|
|
410
|
+
disposition: 'blocked' as const,
|
|
411
|
+
detail:
|
|
412
|
+
'runner cannot classify a skipped file: the RFC 0148 §A requirement ledger is not ' +
|
|
413
|
+
'populated during a run, so skipped/inapplicable/blocked are indistinguishable here. ' +
|
|
414
|
+
'§A resolves an unclassifiable requirement to `blocked`, never to a pass.',
|
|
415
|
+
})),
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
const v2 = {
|
|
419
|
+
bundleVersion: '2' as const,
|
|
420
|
+
generatedAt: new Date().toISOString(),
|
|
421
|
+
generator: { name: '@openwop/openwop-conformance --certify', version },
|
|
422
|
+
suite: { package: '@openwop/openwop-conformance' as const, version },
|
|
423
|
+
host,
|
|
424
|
+
discovery: { url: discoveryUrl, sha256, document },
|
|
425
|
+
claimedProfiles,
|
|
426
|
+
results: {
|
|
427
|
+
totals: {
|
|
428
|
+
executedPass: passed.length,
|
|
429
|
+
executedFail: failed.length,
|
|
430
|
+
skipped: 0,
|
|
431
|
+
inapplicable: 0,
|
|
432
|
+
blocked: skipped.length,
|
|
433
|
+
},
|
|
434
|
+
requirements,
|
|
435
|
+
},
|
|
436
|
+
scenarioManifestSha256: manifestSha,
|
|
437
|
+
targetConfigurationSha256: configSha,
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const v2Schema = JSON.parse(
|
|
441
|
+
readFileSync(join(SCHEMAS_DIR, 'certification-bundle-v2.schema.json'), 'utf8'),
|
|
442
|
+
) as Record<string, unknown>;
|
|
443
|
+
const v2Ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
444
|
+
addFormats(v2Ajv);
|
|
445
|
+
const v2Validate = v2Ajv.compile(v2Schema);
|
|
446
|
+
if (!v2Validate(v2)) {
|
|
447
|
+
process.stderr.write(
|
|
448
|
+
'openwop-conformance --certify: assembled v2 bundle FAILED schema validation:\n' +
|
|
449
|
+
`${JSON.stringify(v2Validate.errors, null, 2)}\n`,
|
|
450
|
+
);
|
|
451
|
+
process.exit(2);
|
|
452
|
+
}
|
|
453
|
+
writeFileSync(outPath, `${JSON.stringify(v2, null, 2)}\n`);
|
|
454
|
+
process.stdout.write(
|
|
455
|
+
`openwop-conformance --certify: wrote bundle v2 to ${outPath}\n` +
|
|
456
|
+
` host: ${host.name}@${host.version}\n` +
|
|
457
|
+
` executed-pass ${passed.length} / executed-fail ${failed.length} / blocked ${skipped.length}\n` +
|
|
458
|
+
(skipped.length > 0
|
|
459
|
+
? ` NOTE: ${skipped.length} requirement(s) are 'blocked', not 'skipped'. The runner cannot\n` +
|
|
460
|
+
` classify a skipped file until scenarios record into the RFC 0148 §A ledger, and §A\n` +
|
|
461
|
+
` resolves an unclassifiable requirement to 'blocked' rather than to a pass. A bundle\n` +
|
|
462
|
+
` with blocked > 0 does NOT certify — that is the true state of the evidence, not a\n` +
|
|
463
|
+
` defect in this emitter.\n`
|
|
464
|
+
: ''),
|
|
465
|
+
);
|
|
466
|
+
process.exit(failed.length > 0 ? 1 : 0);
|
|
467
|
+
}
|
|
468
|
+
|
|
351
469
|
// (e) Validate against the bundle schema BEFORE writing.
|
|
352
470
|
const schema = JSON.parse(
|
|
353
471
|
readFileSync(join(SCHEMAS_DIR, 'conformance-certification-bundle.schema.json'), 'utf8'),
|
package/src/lib/a2a-fake-peer.ts
CHANGED
|
@@ -70,6 +70,20 @@ export interface A2APeerInvocation {
|
|
|
70
70
|
readonly rpcMethod: string | null;
|
|
71
71
|
readonly body: unknown;
|
|
72
72
|
readonly timestamp: number;
|
|
73
|
+
/**
|
|
74
|
+
* Request headers as received, lowercased.
|
|
75
|
+
*
|
|
76
|
+
* Added for RFC 0152 §B, whose central requirement — the sender MUST send
|
|
77
|
+
* `A2A-Version` and a host MUST NOT silently downgrade an authenticated
|
|
78
|
+
* request — lives entirely in the headers. Without capturing them the peer
|
|
79
|
+
* could observe that a call happened but not which version was negotiated,
|
|
80
|
+
* which is the only part §B is about.
|
|
81
|
+
*
|
|
82
|
+
* The absence was itself the blocker: RFC 0152 was being described as needing
|
|
83
|
+
* a live upstream peer when part of what it needed was for this fake one to
|
|
84
|
+
* record what it already received.
|
|
85
|
+
*/
|
|
86
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
export class A2AFakePeer {
|
|
@@ -186,12 +200,18 @@ export class A2AFakePeer {
|
|
|
186
200
|
? String((body as { method: unknown }).method)
|
|
187
201
|
: null;
|
|
188
202
|
|
|
203
|
+
const headers: Record<string, string> = {};
|
|
204
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
205
|
+
if (typeof v === 'string') headers[k.toLowerCase()] = v;
|
|
206
|
+
else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(', ');
|
|
207
|
+
}
|
|
189
208
|
this._invocations.push({
|
|
190
209
|
method: req.method ?? 'GET',
|
|
191
210
|
path: url,
|
|
192
211
|
rpcMethod,
|
|
193
212
|
body,
|
|
194
213
|
timestamp: Date.now(),
|
|
214
|
+
headers,
|
|
195
215
|
});
|
|
196
216
|
|
|
197
217
|
// GET /.well-known/agent-card.json — A2A v0.3 well-known path
|
package/src/lib/behavior-gate.ts
CHANGED
|
@@ -51,6 +51,12 @@
|
|
|
51
51
|
|
|
52
52
|
import { expect } from 'vitest';
|
|
53
53
|
import { loadEnv } from './env.js';
|
|
54
|
+
import { recordRequirement } from './requirement-ledger.js';
|
|
55
|
+
|
|
56
|
+
/** RFC 0148 §A requirement ID for a capability-gated profile decision. */
|
|
57
|
+
export function profileRequirementId(profileName: string): string {
|
|
58
|
+
return `openwop.profile.${profileName}`;
|
|
59
|
+
}
|
|
54
60
|
|
|
55
61
|
/**
|
|
56
62
|
* Returns true if the scenario should proceed with assertions (advertised),
|
|
@@ -69,18 +75,38 @@ export function behaviorGate(profileName: string, advertised: boolean): boolean
|
|
|
69
75
|
const optedOut = env.optedOutProfiles.has(profileName);
|
|
70
76
|
|
|
71
77
|
if (advertised && optedOut) {
|
|
72
|
-
//
|
|
73
|
-
console.warn
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
// RFC 0148 §B: "A host MUST NOT both advertise and opt out of the same
|
|
79
|
+
// profile." This was a `console.warn` that then proceeded as advertised —
|
|
80
|
+
// and a MUST NOT enforced by a warning is not enforced. Nothing consumes the
|
|
81
|
+
// warning, nothing fails on it, and the certification bundle produced from
|
|
82
|
+
// that run records a pass.
|
|
83
|
+
//
|
|
84
|
+
// The two claims are opposite in kind: advertising says the host implements
|
|
85
|
+
// the profile, opting out says the operator declares it does not. A run
|
|
86
|
+
// where both hold has no defensible reading, and resolving it in favour of
|
|
87
|
+
// advertisement extracted MORE certification claim from a MORE
|
|
88
|
+
// contradictory input.
|
|
89
|
+
throw new Error(
|
|
90
|
+
`RFC 0148 §B: [${profileName}] is BOTH advertised by the host AND listed in ` +
|
|
91
|
+
`OPENWOP_OPTED_OUT_PROFILES. A host MUST NOT both advertise and opt out of the same ` +
|
|
92
|
+
`profile — the two are contradictory claims and neither can be trusted while both ` +
|
|
93
|
+
`stand. Remove it from OPENWOP_OPTED_OUT_PROFILES, or stop advertising it.`,
|
|
76
94
|
);
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
if (advertised) return true;
|
|
80
98
|
|
|
81
99
|
if (optedOut) {
|
|
82
|
-
// Honest opt-out: the operator declared the host does not implement
|
|
83
|
-
//
|
|
100
|
+
// Honest opt-out: the operator declared the host does not implement this
|
|
101
|
+
// profile. Skip in BOTH default and strict mode. Recorded as `skipped`
|
|
102
|
+
// rather than left silent — RFC 0148 §A resolves an unrecorded requirement
|
|
103
|
+
// to `blocked`, so without this an opted-out profile and a never-run profile
|
|
104
|
+
// would be the same observable.
|
|
105
|
+
recordRequirement(
|
|
106
|
+
profileRequirementId(profileName),
|
|
107
|
+
'skipped',
|
|
108
|
+
'operator declared an honest opt-out via OPENWOP_OPTED_OUT_PROFILES',
|
|
109
|
+
);
|
|
84
110
|
// eslint-disable-next-line no-console
|
|
85
111
|
console.warn(
|
|
86
112
|
`[${profileName}] honest opt-out (OPENWOP_OPTED_OUT_PROFILES); skipping`,
|
|
@@ -98,7 +124,16 @@ export function behaviorGate(profileName: string, advertised: boolean): boolean
|
|
|
98
124
|
// expect.toBe(true) throws; we won't reach here.
|
|
99
125
|
}
|
|
100
126
|
|
|
101
|
-
// Default-mode soft-skip.
|
|
127
|
+
// Default-mode soft-skip. Recorded as `inapplicable`: the host does not
|
|
128
|
+
// advertise the profile and the operator made no declaration, so the
|
|
129
|
+
// requirement does not apply to this discovery set. That is a different claim
|
|
130
|
+
// from `blocked` ("we could not check"), and it is certifiable where blocked
|
|
131
|
+
// is not — RFC 0148 §A.
|
|
132
|
+
recordRequirement(
|
|
133
|
+
profileRequirementId(profileName),
|
|
134
|
+
'inapplicable',
|
|
135
|
+
'profile not advertised in the captured discovery set',
|
|
136
|
+
);
|
|
102
137
|
// eslint-disable-next-line no-console
|
|
103
138
|
console.warn(
|
|
104
139
|
`[${profileName}] profile not advertised; skipping (set OPENWOP_REQUIRE_BEHAVIOR=true to fail)`,
|
|
@@ -57,6 +57,57 @@ export function expectedCacheKey(input: Record<string, unknown>): string {
|
|
|
57
57
|
return createHash('sha256').update(canonicalize(projectRecipe(input)), 'utf8').digest('hex');
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** RFC 0150 §C recipe stamp. Present in the preimage so a v1 digest and a v2
|
|
61
|
+
* digest for the same request cannot be mistaken for each other. */
|
|
62
|
+
export const SEMANTIC_REQUEST_RECIPE_V2 = 'openwop-semantic-request-v2';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* RFC 0150 §C — project to the **v2** semantic request.
|
|
66
|
+
*
|
|
67
|
+
* The difference from v1 is not additive tidying. v1 EXCLUDED `maxOutputTokens`,
|
|
68
|
+
* `stop`, and `seed`, and every one of them changes the completion — so two
|
|
69
|
+
* requests that produce different text hashed to the same key. That is a wrong
|
|
70
|
+
* hit rather than a miss, which is why v2 is a safety-fix and not a refinement.
|
|
71
|
+
*
|
|
72
|
+
* Transport-only fields are still excluded. The test is whether a field can
|
|
73
|
+
* change what the model returns, not whether it appears in the HTTP request.
|
|
74
|
+
*/
|
|
75
|
+
export function projectSemanticRequestV2(raw: Record<string, unknown>): Record<string, unknown> {
|
|
76
|
+
const request: Record<string, unknown> = { messages: raw.messages };
|
|
77
|
+
if (Array.isArray(raw.tools) && raw.tools.length > 0) {
|
|
78
|
+
request.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => a.name.localeCompare(b.name));
|
|
79
|
+
}
|
|
80
|
+
for (const k of ['temperature', 'topP', 'topK', 'maxOutputTokens', 'seed'] as const) {
|
|
81
|
+
if (typeof raw[k] === 'number') request[k] = raw[k];
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(raw.stop)) request.stop = raw.stop;
|
|
84
|
+
if (raw.responseFormat !== undefined && typeof raw.responseFormat === 'object') {
|
|
85
|
+
request.responseFormat = raw.responseFormat;
|
|
86
|
+
}
|
|
87
|
+
if (raw.safetySettings !== undefined && typeof raw.safetySettings === 'object') {
|
|
88
|
+
request.safetySettings = raw.safetySettings;
|
|
89
|
+
}
|
|
90
|
+
const out: Record<string, unknown> = {
|
|
91
|
+
recipe: SEMANTIC_REQUEST_RECIPE_V2,
|
|
92
|
+
provider: raw.provider,
|
|
93
|
+
model: raw.model,
|
|
94
|
+
request,
|
|
95
|
+
};
|
|
96
|
+
// Carried, never dropped: a dropped option that alters output is
|
|
97
|
+
// indistinguishable from one that was never set.
|
|
98
|
+
if (raw.providerOptions !== undefined && typeof raw.providerOptions === 'object') {
|
|
99
|
+
out.providerOptions = raw.providerOptions;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** RFC 0150 §C — SHA-256 over the JCS-canonical v2 object, lowercase hex. */
|
|
105
|
+
export function semanticRequestDigestV2(input: Record<string, unknown>): string {
|
|
106
|
+
return createHash('sha256')
|
|
107
|
+
.update(canonicalize(projectSemanticRequestV2(input)), 'utf8')
|
|
108
|
+
.digest('hex');
|
|
109
|
+
}
|
|
110
|
+
|
|
60
111
|
/** Drive the host's `POST /v1/host/sample/test/llm-cache-key` test seam.
|
|
61
112
|
* Returns the host's emitted cacheKey when the seam responds 200; status
|
|
62
113
|
* alone when the seam returns 404 (host doesn't expose the seam → caller
|
|
@@ -30,6 +30,20 @@ export interface McpInvocation {
|
|
|
30
30
|
readonly method: string;
|
|
31
31
|
readonly params: unknown;
|
|
32
32
|
readonly timestamp: number;
|
|
33
|
+
/**
|
|
34
|
+
* Request headers as received, lowercased.
|
|
35
|
+
*
|
|
36
|
+
* Added for RFC 0153 §A/§B alongside the identical addition to
|
|
37
|
+
* `A2AFakePeer`. MCP's revision is negotiated in `MCP-Protocol-Version`, so a
|
|
38
|
+
* recorder that captures only the JSON-RPC method and params can see that a
|
|
39
|
+
* call happened and not which revision it was made under — which is the whole
|
|
40
|
+
* of what §B governs.
|
|
41
|
+
*
|
|
42
|
+
* Both fake peers had the same gap, which is worth noting: the omission was
|
|
43
|
+
* not an oversight in one file but a shared assumption that the interesting
|
|
44
|
+
* part of a call is its body.
|
|
45
|
+
*/
|
|
46
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
export class McpFakeServer {
|
|
@@ -93,10 +107,16 @@ export class McpFakeServer {
|
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
if (typeof rpc.method === 'string') {
|
|
110
|
+
const headers: Record<string, string> = {};
|
|
111
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
112
|
+
if (typeof v === 'string') headers[k.toLowerCase()] = v;
|
|
113
|
+
else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(', ');
|
|
114
|
+
}
|
|
96
115
|
this._invocations.push({
|
|
97
116
|
method: rpc.method,
|
|
98
117
|
params: rpc.params ?? null,
|
|
99
118
|
timestamp: Date.now(),
|
|
119
|
+
headers,
|
|
100
120
|
});
|
|
101
121
|
}
|
|
102
122
|
|
package/src/lib/profiles.ts
CHANGED
|
@@ -83,6 +83,14 @@ function isNonNegativeInteger(value: unknown): value is number {
|
|
|
83
83
|
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* RFC 0149 §C — `protocolVersion` is ASCII `<major>.<minor>`, no leading zero
|
|
88
|
+
* except zero itself. Kept identical to the `pattern` in
|
|
89
|
+
* `schemas/capabilities.schema.json`; `protocol-version-grammar.test.ts` asserts
|
|
90
|
+
* the two agree, so this cannot drift into being the looser of the pair again.
|
|
91
|
+
*/
|
|
92
|
+
const PROTOCOL_VERSION_GRAMMAR = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
|
93
|
+
|
|
86
94
|
/**
|
|
87
95
|
* `openwop-core` predicate. Every other profile implies `openwop-core`. A host
|
|
88
96
|
* that fails this predicate is not openwop-compatible.
|
|
@@ -91,7 +99,13 @@ function isNonNegativeInteger(value: unknown): value is number {
|
|
|
91
99
|
*/
|
|
92
100
|
export function isCore(c: DiscoveryPayload): boolean {
|
|
93
101
|
if (typeof c.protocolVersion !== 'string') return false;
|
|
94
|
-
|
|
102
|
+
// RFC 0149 §C. `startsWith('1.')` admitted `1.0.0` and `1.banana` while
|
|
103
|
+
// rejecting `1`, so the predicate deciding whether a host is openwop-compatible
|
|
104
|
+
// at all was looser than the schema every host validates against. The major is
|
|
105
|
+
// the hard boundary; a higher minor stays core under v1 additive rules.
|
|
106
|
+
const version = PROTOCOL_VERSION_GRAMMAR.exec(c.protocolVersion);
|
|
107
|
+
if (version === null) return false;
|
|
108
|
+
if (version[1] !== '1') return false;
|
|
95
109
|
if (!Array.isArray(c.supportedEnvelopes)) return false;
|
|
96
110
|
if (!c.supportedEnvelopes.every((entry) => typeof entry === 'string')) return false;
|
|
97
111
|
if (typeof c.schemaVersions !== 'object' || c.schemaVersions === null) return false;
|
|
@@ -452,6 +466,14 @@ export interface ProfileFloor {
|
|
|
452
466
|
readonly required: readonly string[];
|
|
453
467
|
/** Prefix groups where ≥1 matching passed scenario satisfies the group. */
|
|
454
468
|
readonly requiredAnyPrefix?: readonly string[];
|
|
469
|
+
/**
|
|
470
|
+
* The profile is discovery-payload-only: its predicate IS the whole claim and
|
|
471
|
+
* it has no runtime floor. This flag exists so an EMPTY floor is a decision on
|
|
472
|
+
* record rather than an absence — an absent key means "not yet transcribed",
|
|
473
|
+
* which `verifyBundleProfile()` treats as unprovable (RFC 0148 §C). Conflating
|
|
474
|
+
* the two is what let five undefined-floor claims verify as proven.
|
|
475
|
+
*/
|
|
476
|
+
readonly discoveryOnly?: true;
|
|
455
477
|
}
|
|
456
478
|
|
|
457
479
|
export const PROFILE_FLOOR_SCENARIOS: Readonly<Record<string, ProfileFloor>> = {
|
|
@@ -469,6 +491,49 @@ export const PROFILE_FLOOR_SCENARIOS: Readonly<Record<string, ProfileFloor>> = {
|
|
|
469
491
|
],
|
|
470
492
|
requiredAnyPrefix: ['interrupt-'],
|
|
471
493
|
},
|
|
494
|
+
|
|
495
|
+
// ── Floors TRANSCRIBED from `profiles.md`, not invented here ────────────────
|
|
496
|
+
// `profiles.md` §"Claiming vs passing" already states the rule normatively:
|
|
497
|
+
// "A host CLAIMS a profile by satisfying its predicate AND passing the
|
|
498
|
+
// conformance scenarios labelled with the profile tag." Each per-profile
|
|
499
|
+
// section then names its scenarios. This map was an incomplete transcription
|
|
500
|
+
// of that prose, and every profile it omitted verified as floor-proven
|
|
501
|
+
// against nothing (RFC 0148 §C, gap G6).
|
|
502
|
+
|
|
503
|
+
// `profiles.md` §`openwop-core` is a pure discovery-payload predicate — it
|
|
504
|
+
// names no runtime scenario, so the predicate IS the whole claim.
|
|
505
|
+
'openwop-core': { required: [], discoveryOnly: true },
|
|
506
|
+
|
|
507
|
+
// `profiles.md` §`openwop-fixtures`: "The profile is discovery-payload-only —
|
|
508
|
+
// it confirms the host claims SOME fixture, not that any specific fixture is
|
|
509
|
+
// wired."
|
|
510
|
+
'openwop-fixtures': { required: [], discoveryOnly: true },
|
|
511
|
+
|
|
512
|
+
// `profiles.md` §`openwop-stream-sse`: "A host passes `openwop-stream-sse`
|
|
513
|
+
// when discovery passes the predicate AND those scenarios pass."
|
|
514
|
+
'openwop-stream-sse': {
|
|
515
|
+
required: ['stream-modes.test.ts', 'stream-modes-buffer.test.ts', 'stream-modes-mixed.test.ts'],
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
// `profiles.md` §`openwop-stream-poll`: "Conformance scenarios in
|
|
519
|
+
// `stream-modes.test.ts` exercise polling."
|
|
520
|
+
'openwop-stream-poll': { required: ['stream-modes.test.ts'] },
|
|
521
|
+
|
|
522
|
+
// `profiles.md` §`openwop-node-packs`: "a host passes `openwop-node-packs`
|
|
523
|
+
// when it passes those scenarios."
|
|
524
|
+
'openwop-node-packs': { required: ['pack-registry.test.ts', 'pack-registry-publish.test.ts'] },
|
|
525
|
+
|
|
526
|
+
// ── Deliberately NOT transcribed ───────────────────────────────────────────
|
|
527
|
+
// `openwop-replay-fork` cannot be expressed by this model. `profiles.md`
|
|
528
|
+
// §`openwop-replay-fork` says "Hosts MAY support either or both modes; the
|
|
529
|
+
// conformance scenarios pass on whichever mode the host advertises" — a floor
|
|
530
|
+
// conditional on the advertised mode, which a flat `required[]` cannot state.
|
|
531
|
+
// Forcing it would either over-require (failing an honest single-mode host) or
|
|
532
|
+
// under-require (the vacuity this fix removes). It stays unspecified, and is
|
|
533
|
+
// therefore unprovable, until the model can express a discovery-conditional
|
|
534
|
+
// floor. Same for `openwop-interrupts`, `openwop-secrets`,
|
|
535
|
+
// `openwop-provider-policy`, `openwop-memory`, and `openwop-trigger-bridge`,
|
|
536
|
+
// whose prose sections do not yet name a settled floor set.
|
|
472
537
|
};
|
|
473
538
|
|
|
474
539
|
/** Is `profile` derivable from a discovery document? Maps a profile name to its predicate (RFC 0089 §B(1)). */
|
|
@@ -496,6 +561,14 @@ export interface BundleProfileVerdict {
|
|
|
496
561
|
readonly floorProven: boolean;
|
|
497
562
|
readonly valid: boolean;
|
|
498
563
|
readonly missingFloor: readonly string[];
|
|
564
|
+
/**
|
|
565
|
+
* True when no floor set is defined for this profile, so §B(2) cannot be
|
|
566
|
+
* evaluated at all. Distinct from `floorProven: false` with a populated
|
|
567
|
+
* `missingFloor`, which means the floor WAS evaluated and the bundle failed
|
|
568
|
+
* it. Both are invalid; only this one is a gap in the corpus rather than a
|
|
569
|
+
* defect in the host.
|
|
570
|
+
*/
|
|
571
|
+
readonly floorUnspecified: boolean;
|
|
499
572
|
}
|
|
500
573
|
|
|
501
574
|
const scenarioBasename = (id: string): string => id.split('/').pop() ?? id;
|
|
@@ -507,11 +580,29 @@ const scenarioBasename = (id: string): string => id.split('/').pop() ?? id;
|
|
|
507
580
|
export function verifyBundleProfile(bundle: CertificationBundleLike, profile: string): BundleProfileVerdict {
|
|
508
581
|
const derivable = profileDerivable(bundle.discovery.document, profile);
|
|
509
582
|
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
583
|
+
|
|
584
|
+
// An UNDEFINED floor set makes §B(2) unevaluable, so the claim is unprovable —
|
|
585
|
+
// never proven. Reading the old code, `missingFloor` fell to `[]` and
|
|
586
|
+
// `prefixOk` to `[].every(...)` === true, so `floorProven` came out TRUE for
|
|
587
|
+
// every profile this map omitted: the verifier reported a claim as floor-proven
|
|
588
|
+
// having checked nothing. An empty floor that is a real decision says so with
|
|
589
|
+
// `discoveryOnly` (RFC 0148 §C, gap G6).
|
|
590
|
+
if (floor === undefined) {
|
|
591
|
+
return { profile, derivable, floorProven: false, valid: false, missingFloor: [], floorUnspecified: true };
|
|
592
|
+
}
|
|
593
|
+
|
|
510
594
|
const passed = new Set(bundle.results.passed.map(scenarioBasename));
|
|
511
|
-
const missingFloor = floor
|
|
512
|
-
const prefixOk = (floor
|
|
595
|
+
const missingFloor = floor.required.filter((r) => !passed.has(scenarioBasename(r)));
|
|
596
|
+
const prefixOk = (floor.requiredAnyPrefix ?? []).every((p) => [...passed].some((s) => s.startsWith(p)));
|
|
513
597
|
const floorProven = missingFloor.length === 0 && prefixOk;
|
|
514
|
-
return {
|
|
598
|
+
return {
|
|
599
|
+
profile,
|
|
600
|
+
derivable,
|
|
601
|
+
floorProven,
|
|
602
|
+
valid: derivable && floorProven,
|
|
603
|
+
missingFloor,
|
|
604
|
+
floorUnspecified: false,
|
|
605
|
+
};
|
|
515
606
|
}
|
|
516
607
|
|
|
517
608
|
/** Verify every profile in `bundle.claimedProfiles`; the bundle is valid iff all claims are valid. */
|