@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
|
@@ -1576,16 +1576,30 @@ describe.skipIf(RFC0089_BUNDLE_PATH === null || !existsSync(RFC0089_BUNDLE_PATH)
|
|
|
1576
1576
|
expect(ok, JSON.stringify(validate.errors)).toBe(true);
|
|
1577
1577
|
});
|
|
1578
1578
|
|
|
1579
|
-
it('verifyBundle
|
|
1579
|
+
it('verifyBundle REJECTS the committed reference bundle — it is `invalidated` (RFC 0148 §D)', () => {
|
|
1580
|
+
// This assertion was inverted on 2026-08-12. It previously asserted the
|
|
1581
|
+
// bundle was ACCEPTED, commented "the host honestly claims ONLY profiles its
|
|
1582
|
+
// discovery document derives, none of which it fails a floor scenario for."
|
|
1583
|
+
// That comment was false: the bundle claims `openwop-stream-sse` while all
|
|
1584
|
+
// three `stream-modes*` scenarios sit in its own `results.failed`. The old
|
|
1585
|
+
// assertion passed only because those profiles had no floor definition, so
|
|
1586
|
+
// `floorProven` came out vacuously true — a test defending a claim the
|
|
1587
|
+
// bundle's own failure list contradicts.
|
|
1588
|
+
//
|
|
1589
|
+
// The bundle is now marked `invalidated` in
|
|
1590
|
+
// `docs/CERTIFICATION-BUNDLE-INVENTORY.md`; reissue requires bundle v2.
|
|
1591
|
+
// Until then the correct expectation is rejection, and the reasons are
|
|
1592
|
+
// asserted individually so a future reissue cannot turn this green for the
|
|
1593
|
+
// wrong cause.
|
|
1580
1594
|
const bundle = readJson(bundlePath) as Parameters<typeof verifyBundle>[0];
|
|
1581
1595
|
const r = verifyBundle(bundle);
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
const
|
|
1585
|
-
expect(
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
)
|
|
1596
|
+
expect(r.valid, 'the committed v1 bundle is invalidated, not merely historical').toBe(false);
|
|
1597
|
+
|
|
1598
|
+
const sse = r.verdicts.find((v) => v.profile === 'openwop-stream-sse');
|
|
1599
|
+
expect(sse?.floorProven, 'profiles.md §openwop-stream-sse: predicate AND those scenarios pass').toBe(false);
|
|
1600
|
+
expect(sse?.missingFloor, 'its own results.failed lists the stream-modes scenarios').toContain(
|
|
1601
|
+
'stream-modes.test.ts',
|
|
1602
|
+
);
|
|
1589
1603
|
});
|
|
1590
1604
|
|
|
1591
1605
|
it('discovery.sha256 is the canonical-JSON SHA-256 of the captured discovery.document', () => {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0148 §B — strict behavior, and the MUST NOT that was enforced by a warning.
|
|
3
|
+
*
|
|
4
|
+
* §B: "When a host advertises a capability, every required behavioral assertion
|
|
5
|
+
* for that capability MUST execute or fail. `OPENWOP_REQUIRE_BEHAVIOR=true` MUST
|
|
6
|
+
* fail on `blocked`, unclassified early return, or a missing seam unless the
|
|
7
|
+
* profile was explicitly opted out before discovery capture. **A host MUST NOT
|
|
8
|
+
* both advertise and opt out of the same profile.**"
|
|
9
|
+
*
|
|
10
|
+
* That last sentence was not enforced. `behaviorGate()` detected the
|
|
11
|
+
* contradiction, emitted `console.warn`, and proceeded as if advertised. A
|
|
12
|
+
* warning in a conformance run is not a check — nothing consumes it, nothing
|
|
13
|
+
* fails on it, and a certification bundle produced from that run records a pass.
|
|
14
|
+
*
|
|
15
|
+
* It matters because the two claims are opposite in kind. Advertising says *this
|
|
16
|
+
* host implements the profile*; opting out says *the operator declares it does
|
|
17
|
+
* not*. A run where both are true has no defensible reading, and the one the
|
|
18
|
+
* gate chose — advertisement wins — is the one that produces MORE certification
|
|
19
|
+
* claim from a MORE contradictory input.
|
|
20
|
+
*
|
|
21
|
+
* The second half wires §B to §A: a gate decision now records a ledger
|
|
22
|
+
* disposition, so "skipped because opted out" and "not exercised at all" stop
|
|
23
|
+
* being the same observable. Before the ledger there was nothing to record into,
|
|
24
|
+
* which is why §B could not be implemented before §A.
|
|
25
|
+
*
|
|
26
|
+
* Server-free. Manipulates process env, so it restores it per-test.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
30
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
31
|
+
import { dispositionOf, resetLedger } from '../lib/requirement-ledger.js';
|
|
32
|
+
import { __resetEnvCacheForTests } from '../lib/env.js';
|
|
33
|
+
|
|
34
|
+
const ENV_KEYS = ['OPENWOP_REQUIRE_BEHAVIOR', 'OPENWOP_OPTED_OUT_PROFILES', 'OPENWOP_BASE_URL', 'OPENWOP_API_KEY'] as const;
|
|
35
|
+
let saved: Record<string, string | undefined> = {};
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
resetLedger();
|
|
39
|
+
saved = {};
|
|
40
|
+
for (const k of ENV_KEYS) {
|
|
41
|
+
saved[k] = process.env[k];
|
|
42
|
+
delete process.env[k];
|
|
43
|
+
}
|
|
44
|
+
// `loadEnv()` requires a base URL AND an api key even on paths that reach no
|
|
45
|
+
// host, and it caches — so both are stubbed and the cache dropped per test.
|
|
46
|
+
process.env['OPENWOP_BASE_URL'] = 'https://conformance.invalid';
|
|
47
|
+
process.env['OPENWOP_API_KEY'] = 'dummy-not-used-no-request-is-made';
|
|
48
|
+
__resetEnvCacheForTests();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
for (const k of ENV_KEYS) {
|
|
53
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
54
|
+
else process.env[k] = saved[k];
|
|
55
|
+
}
|
|
56
|
+
__resetEnvCacheForTests();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('RFC 0148 §B — advertise and opt-out are mutually exclusive', () => {
|
|
60
|
+
it('advertising AND opting out of the same profile fails', () => {
|
|
61
|
+
// The defect: this was a `console.warn` that then proceeded as advertised.
|
|
62
|
+
// A MUST NOT enforced by a warning is not enforced — nothing consumes the
|
|
63
|
+
// warning, and the bundle produced from that run records a pass.
|
|
64
|
+
process.env['OPENWOP_OPTED_OUT_PROFILES'] = 'openwop-audit-log-integrity';
|
|
65
|
+
__resetEnvCacheForTests();
|
|
66
|
+
expect(
|
|
67
|
+
() => behaviorGate('openwop-audit-log-integrity', true),
|
|
68
|
+
'RFC 0148 §B: "A host MUST NOT both advertise and opt out of the same profile." ' +
|
|
69
|
+
'The two claims are opposite in kind — one says the host implements the profile, the ' +
|
|
70
|
+
'other says the operator declares it does not. A run where both hold has no defensible ' +
|
|
71
|
+
'reading, and resolving it in favour of advertisement extracts MORE certification claim ' +
|
|
72
|
+
'from a MORE contradictory input.',
|
|
73
|
+
).toThrow(/MUST NOT both advertise and opt out/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('advertising alone proceeds', () => {
|
|
77
|
+
expect(behaviorGate('openwop-audit-log-integrity', true)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('opting out alone skips, in strict mode too', () => {
|
|
81
|
+
process.env['OPENWOP_OPTED_OUT_PROFILES'] = 'openwop-audit-log-integrity';
|
|
82
|
+
__resetEnvCacheForTests();
|
|
83
|
+
process.env['OPENWOP_REQUIRE_BEHAVIOR'] = 'true';
|
|
84
|
+
__resetEnvCacheForTests();
|
|
85
|
+
expect(behaviorGate('openwop-audit-log-integrity', false)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('strict mode fails an unadvertised, non-opted-out profile', () => {
|
|
89
|
+
process.env['OPENWOP_REQUIRE_BEHAVIOR'] = 'true';
|
|
90
|
+
__resetEnvCacheForTests();
|
|
91
|
+
expect(() => behaviorGate('openwop-audit-log-integrity', false)).toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('RFC 0148 §B — gate decisions record a ledger disposition', () => {
|
|
96
|
+
it('an honest opt-out records `skipped`, not silence', () => {
|
|
97
|
+
// §A resolves an unrecorded requirement to `blocked`. Without this wiring an
|
|
98
|
+
// opted-out profile and a never-run profile are the same observable, which
|
|
99
|
+
// is the distinction §B exists to make.
|
|
100
|
+
process.env['OPENWOP_OPTED_OUT_PROFILES'] = 'openwop-audit-log-integrity';
|
|
101
|
+
__resetEnvCacheForTests();
|
|
102
|
+
behaviorGate('openwop-audit-log-integrity', false);
|
|
103
|
+
expect(dispositionOf('openwop.profile.openwop-audit-log-integrity')).toBe('skipped');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('a default-mode soft-skip records `inapplicable`', () => {
|
|
107
|
+
// The host does not advertise it and the operator made no declaration, so
|
|
108
|
+
// the requirement does not apply to this discovery set. That is a different
|
|
109
|
+
// statement from "we could not check", and it is certifiable where
|
|
110
|
+
// `blocked` is not.
|
|
111
|
+
behaviorGate('openwop-audit-log-integrity', false);
|
|
112
|
+
expect(dispositionOf('openwop.profile.openwop-audit-log-integrity')).toBe('inapplicable');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('an unexercised profile stays `blocked`', () => {
|
|
116
|
+
// Nothing called the gate at all. §A's default holds: silence is not a pass,
|
|
117
|
+
// and it is not certifiable either.
|
|
118
|
+
expect(dispositionOf('openwop.profile.openwop-never-touched')).toBe('blocked');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0152 + RFC 0153 — versioned A2A and MCP composition, shape only.
|
|
3
|
+
*
|
|
4
|
+
* **Stated first, because RFC 0147 §A.5 turns on it:** this file proves the
|
|
5
|
+
* discovery schema admits the versioned shapes §A describes and rejects the ones
|
|
6
|
+
* it forbids. It contacts no peer. It is therefore **not** evidence that this
|
|
7
|
+
* host interoperates with any A2A 1.0 or MCP 2026-07-28 implementation, and
|
|
8
|
+
* neither RFC can reach a defensible `Accepted` on it. Both additionally require
|
|
9
|
+
* a real upstream peer in CI, which is not a corpus deliverable.
|
|
10
|
+
*
|
|
11
|
+
* The defect both RFCs address is the same, which is why they land together:
|
|
12
|
+
* **`supported: true` with no version is a claim a peer cannot negotiate
|
|
13
|
+
* against.** It asserts the host speaks *some* A2A or *some* MCP. Two hosts can
|
|
14
|
+
* both advertise it, share no revision, and discover that only when a call
|
|
15
|
+
* fails — and the failure surfaces at the peer, not at the handshake that was
|
|
16
|
+
* supposed to prevent it. Versioning the advertisement moves the disagreement to
|
|
17
|
+
* the one place both sides are looking.
|
|
18
|
+
*
|
|
19
|
+
* Two shape choices worth keeping:
|
|
20
|
+
*
|
|
21
|
+
* - **MCP versions are date-patterned, not free strings.** MCP revisions *are*
|
|
22
|
+
* dates. Accepting `latest` or `2026-7-28` would let two hosts disagree
|
|
23
|
+
* about which revision they share while both validate.
|
|
24
|
+
* - **The MCP feature list is closed.** An unrecognized feature name is
|
|
25
|
+
* indistinguishable from a typo, and a peer that silently ignores one has
|
|
26
|
+
* negotiated a capability neither side implements.
|
|
27
|
+
*
|
|
28
|
+
* Server-free.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { readFileSync } from 'node:fs';
|
|
33
|
+
import { join, resolve as pathResolve } from 'node:path';
|
|
34
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
35
|
+
import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Schemas ship inside the package; RFC prose does not. Deriving both from
|
|
39
|
+
* `V1_DIR` — null in the published tarball — and casting the null away with
|
|
40
|
+
* `as string` is what took this file down at import when installed from npm.
|
|
41
|
+
*/
|
|
42
|
+
const RFCS_DIR = V1_DIR === null ? null : pathResolve(V1_DIR, '..', '..', 'RFCS');
|
|
43
|
+
|
|
44
|
+
function caps(): Record<string, unknown> {
|
|
45
|
+
return JSON.parse(
|
|
46
|
+
readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'),
|
|
47
|
+
) as Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Compile one capability family in isolation. */
|
|
51
|
+
function familyValidator(family: string) {
|
|
52
|
+
const schema = caps() as { properties: Record<string, object> };
|
|
53
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
54
|
+
return ajv.compile(schema.properties[family] as object);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('RFC 0152 §A — A2A versioned discovery', () => {
|
|
58
|
+
const validate = familyValidator('a2a');
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The `a2a` family independently requires `agentCardUrl` (RFC 0100: it must
|
|
62
|
+
* GET-resolve credential-less). Every fixture below carries it.
|
|
63
|
+
*
|
|
64
|
+
* This is not tidiness. Without it the NEGATIVE legs would have failed
|
|
65
|
+
* validation for the missing card URL rather than for the version defect they
|
|
66
|
+
* name — passing for the wrong reason, which is the vacuity this whole
|
|
67
|
+
* program exists to close, committed inside a test written to prevent it.
|
|
68
|
+
*/
|
|
69
|
+
const a2a = (over: Record<string, unknown>) => ({
|
|
70
|
+
supported: true,
|
|
71
|
+
agentCardUrl: 'https://host.example/.well-known/agent-card.json',
|
|
72
|
+
...over,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('a versioned advertisement validates', () => {
|
|
76
|
+
expect(
|
|
77
|
+
validate(a2a({
|
|
78
|
+
protocolVersions: ['1.0', '0.3'],
|
|
79
|
+
preferredVersion: '1.0',
|
|
80
|
+
profiles: ['a2a-1.0', 'a2a-0.3-legacy'],
|
|
81
|
+
durableTasks: true,
|
|
82
|
+
})),
|
|
83
|
+
JSON.stringify(validate.errors),
|
|
84
|
+
).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('a legacy-only host can say so explicitly', () => {
|
|
88
|
+
// The point of naming the legacy profile: a deprecation you can see is one
|
|
89
|
+
// you can time-bound. A bare `supported: true` hides the same fact.
|
|
90
|
+
expect(
|
|
91
|
+
validate(a2a({ protocolVersions: ['0.3'], preferredVersion: '0.3', profiles: ['a2a-0.3-legacy'] })),
|
|
92
|
+
JSON.stringify(validate.errors),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('an empty version list is rejected', () => {
|
|
97
|
+
expect(
|
|
98
|
+
validate(a2a({ protocolVersions: [] })),
|
|
99
|
+
'RFC 0152 §A: an A2A-capable host MUST advertise a NON-EMPTY `protocolVersions`. An empty ' +
|
|
100
|
+
'array is the bare `supported: true` problem wearing a schema — it validates and tells a ' +
|
|
101
|
+
'peer nothing it can negotiate against.',
|
|
102
|
+
).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('duplicate versions are rejected', () => {
|
|
106
|
+
expect(validate(a2a({ protocolVersions: ['1.0', '1.0'] }))).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('a malformed version is rejected', () => {
|
|
110
|
+
for (const v of ['1', 'v1.0', 'latest', '1.0.0']) {
|
|
111
|
+
expect(
|
|
112
|
+
validate(a2a({ protocolVersions: [v] })),
|
|
113
|
+
`RFC 0152 §A: '${v}' is not an A2A major.minor version`,
|
|
114
|
+
).toBe(false);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('a malformed profile id is rejected', () => {
|
|
119
|
+
expect(validate(a2a({ protocolVersions: ['1.0'], profiles: ['a2a-latest'] }))).toBe(false);
|
|
120
|
+
expect(validate(a2a({ protocolVersions: ['1.0'], profiles: ['a2a-1.0'] }))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('RFC 0153 §A — MCP versioned discovery', () => {
|
|
125
|
+
const validate = familyValidator('mcp');
|
|
126
|
+
|
|
127
|
+
it('a versioned advertisement validates', () => {
|
|
128
|
+
expect(
|
|
129
|
+
validate({
|
|
130
|
+
supported: true,
|
|
131
|
+
protocolVersions: ['2026-07-28', '2025-06-18'],
|
|
132
|
+
preferredVersion: '2026-07-28',
|
|
133
|
+
profiles: ['mcp-2026-07-28', 'mcp-2025-06-18-legacy'],
|
|
134
|
+
features: ['server-discover', 'mrtr', 'cacheable-lists', 'extensions'],
|
|
135
|
+
}),
|
|
136
|
+
JSON.stringify(validate.errors),
|
|
137
|
+
).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('versions use MCP date form exactly', () => {
|
|
141
|
+
// MCP revisions ARE dates. `latest` or a non-padded month would let two
|
|
142
|
+
// hosts disagree about which revision they share while both validate.
|
|
143
|
+
for (const v of ['latest', '2026-7-28', '2026-07', 'v2026-07-28']) {
|
|
144
|
+
expect(
|
|
145
|
+
validate({ supported: true, protocolVersions: [v] }),
|
|
146
|
+
`RFC 0153 §A: '${v}' is not MCP's date form`,
|
|
147
|
+
).toBe(false);
|
|
148
|
+
}
|
|
149
|
+
expect(validate({ supported: true, protocolVersions: ['2026-07-28'] })).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('the feature list is closed', () => {
|
|
153
|
+
expect(
|
|
154
|
+
validate({ supported: true, protocolVersions: ['2026-07-28'], features: ['server-discover', 'telepathy'] }),
|
|
155
|
+
'RFC 0153 §A: an unrecognized feature name is indistinguishable from a typo, and a peer ' +
|
|
156
|
+
'that silently ignores one has negotiated a capability neither side implements.',
|
|
157
|
+
).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('an empty version list is rejected', () => {
|
|
161
|
+
expect(validate({ supported: true, protocolVersions: [] })).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe.skipIf(RFCS_DIR === null)('RFC 0152 + 0153 — what these do NOT establish', () => {
|
|
166
|
+
it('both RFCs keep their upstream-peer acceptance items unticked and annotated', () => {
|
|
167
|
+
// RFC 0147 §A.5 forbids `Accepted` on shape-only evidence for a behavioral
|
|
168
|
+
// requirement. Schema validity is not interop: nothing here speaks to a
|
|
169
|
+
// peer, and both RFCs require a real upstream implementation in CI. This
|
|
170
|
+
// leg exists so the suite cannot read as more than it is.
|
|
171
|
+
for (const [file, needle] of [
|
|
172
|
+
['0152-a2a-1-0-versioned-composition.md', 'Real upstream A2A 1.0 peer passes in CI.'],
|
|
173
|
+
['0153-mcp-2026-07-28-versioned-composition.md', 'Pinned real MCP current peer passes in CI.'],
|
|
174
|
+
] as const) {
|
|
175
|
+
const rfc = readFileSync(join(RFCS_DIR as string, file), 'utf8');
|
|
176
|
+
expect(
|
|
177
|
+
new RegExp(`- \\[ \\] ${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`).test(rfc),
|
|
178
|
+
`${file}: the upstream-peer item MUST remain unticked and annotated — no peer is contacted ` +
|
|
179
|
+
'anywhere in this corpus, and interop is the one thing a schema cannot demonstrate.',
|
|
180
|
+
).toBe(true);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0154 §A/§B — the workload-identity BEHAVIORAL witness.
|
|
3
|
+
*
|
|
4
|
+
* §A's requirements are behavioral and, from the wire alone, **invisible**: a
|
|
5
|
+
* host must cryptographically verify the presented identity, bind it to the
|
|
6
|
+
* request, resolve it to an OpenWOP principal *before* authorization, and fail
|
|
7
|
+
* closed when it cannot. A normal request either succeeds or 401s, and **both
|
|
8
|
+
* outcomes look identical whether the host verified anything or simply trusted
|
|
9
|
+
* a header.**
|
|
10
|
+
*
|
|
11
|
+
* That invisibility is why `host-sample-test-seams.md` §20 exists, and why this
|
|
12
|
+
* file reports a missing seam as `blocked` rather than skipping quietly. RFC
|
|
13
|
+
* 0148 §A resolves an unobservable requirement to `blocked`, never to a pass —
|
|
14
|
+
* so without the seam, RFC 0154 cannot be certified at all. That is the honest
|
|
15
|
+
* position, and stating it is the point.
|
|
16
|
+
*
|
|
17
|
+
* **The negative cases carry the weight here.** A host that echoes its input can
|
|
18
|
+
* satisfy every positive assertion; only the rejections distinguish a verifier
|
|
19
|
+
* from a passthrough. So each negative names the specific confusion it rules
|
|
20
|
+
* out:
|
|
21
|
+
*
|
|
22
|
+
* - **audience mismatch** — an identity minted for another host, accepted
|
|
23
|
+
* here, is how a credential valid elsewhere becomes valid here;
|
|
24
|
+
* - **expired delegation** — a delegation without a live expiry is a standing
|
|
25
|
+
* grant, which is not what delegation means;
|
|
26
|
+
* - **missing sender constraint** — without proof-of-possession a bearer
|
|
27
|
+
* credential is replayable by anyone who observed it, so the host would be
|
|
28
|
+
* asserting *who called* rather than *that the caller held the key*.
|
|
29
|
+
*
|
|
30
|
+
* And the framing that no assertion can enforce, stated because it governs how
|
|
31
|
+
* a `200` here should be read: **identity is not authorization** (RFC 0147 R12).
|
|
32
|
+
* A resolution means the identity resolved. It never means the caller may act.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, expect } from 'vitest';
|
|
36
|
+
import { driver } from '../lib/driver.js';
|
|
37
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
38
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
39
|
+
|
|
40
|
+
const PROFILE = 'openwop-workload-identity';
|
|
41
|
+
/**
|
|
42
|
+
* §B gates on its OWN flag, not on §A's.
|
|
43
|
+
*
|
|
44
|
+
* The first draft of this file gated every leg on `workloadIdentity.supported`,
|
|
45
|
+
* which meant a host that verifies workload identity but does NOT implement
|
|
46
|
+
* delegation had two options: advertise and hard-fail the §B legs under strict
|
|
47
|
+
* mode, or advertise nothing and get no witness for the §A behavior it does
|
|
48
|
+
* have. That is the overclaim-or-silence bind RFC 0155 §A names for
|
|
49
|
+
* `openwop-core`, reproduced one capability down.
|
|
50
|
+
*
|
|
51
|
+
* A tier-1 host caught it by asking whether the scenarios gate per-section or
|
|
52
|
+
* all-on-`supported`. They gate per-section now. §A-only is an honest,
|
|
53
|
+
* witnessable posture.
|
|
54
|
+
*/
|
|
55
|
+
const DELEGATION_PROFILE = 'openwop-workload-identity-delegation';
|
|
56
|
+
const SEAM = '/v1/host/sample/test/workload-identity/resolve';
|
|
57
|
+
|
|
58
|
+
interface AuthCaps {
|
|
59
|
+
readonly workloadIdentity?: {
|
|
60
|
+
readonly supported?: boolean;
|
|
61
|
+
readonly schemes?: readonly string[];
|
|
62
|
+
readonly senderConstraint?: readonly string[];
|
|
63
|
+
readonly delegation?: { readonly supported?: boolean; readonly maxChainDepth?: number };
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function caps(): Promise<AuthCaps['workloadIdentity']> {
|
|
68
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
69
|
+
return capabilityFamily<AuthCaps>(disco.json, 'auth')?.workloadIdentity;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A seam response, or `null` when the seam is absent. */
|
|
73
|
+
async function resolve(body: Record<string, unknown>): Promise<{ status: number; json: unknown } | null> {
|
|
74
|
+
const r = await driver.post(SEAM, body);
|
|
75
|
+
return r.status === 404 || r.status === 403 ? null : { status: r.status, json: r.json };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reasonOf(json: unknown): string | undefined {
|
|
79
|
+
return (json as { error?: { code?: string } })?.error?.code;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const IDENTITY = { scheme: 'spiffe', subject: 'spiffe://example/dispatcher', issuer: 'spiffe://example' };
|
|
83
|
+
|
|
84
|
+
describe('RFC 0154 §A — workload identity resolution (capability-gated behavior)', () => {
|
|
85
|
+
it('the seam is wired, or the requirement is blocked rather than skipped', async () => {
|
|
86
|
+
if (!behaviorGate(PROFILE, (await caps())?.supported === true)) return;
|
|
87
|
+
const r = await resolve({ identity: { ...IDENTITY, audience: 'openwop-host' } });
|
|
88
|
+
expect(
|
|
89
|
+
r,
|
|
90
|
+
driver.describe(
|
|
91
|
+
'spec/v1/host-sample-test-seams.md §20',
|
|
92
|
+
'a host advertising `auth.workloadIdentity` MUST expose the resolution seam. §A\'s ' +
|
|
93
|
+
'requirements — verify, bind, resolve-before-authorize, fail closed — are invisible from ' +
|
|
94
|
+
'a normal request, where success and 401 look identical whether the host verified ' +
|
|
95
|
+
'anything or trusted a header. RFC 0148 §A resolves an unobservable requirement to ' +
|
|
96
|
+
'`blocked`, so without this seam RFC 0154 cannot be certified at all.',
|
|
97
|
+
),
|
|
98
|
+
).not.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('a verified identity resolves to a principal', async () => {
|
|
102
|
+
if (!behaviorGate(PROFILE, (await caps())?.supported === true)) return;
|
|
103
|
+
const r = await resolve({ identity: { ...IDENTITY, audience: 'openwop-host' } });
|
|
104
|
+
if (r === null) return;
|
|
105
|
+
expect(r.status, driver.describe('RFCS/0154 §A', 'a verified identity resolves')).toBe(200);
|
|
106
|
+
const principalId = (r.json as { principalId?: string }).principalId;
|
|
107
|
+
expect(principalId, driver.describe('RFCS/0154 §A', 'resolution yields an OpenWOP principal')).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('an identity for another audience is rejected', async () => {
|
|
111
|
+
if (!behaviorGate(PROFILE, (await caps())?.supported === true)) return;
|
|
112
|
+
const r = await resolve({ identity: { ...IDENTITY, audience: 'some-other-host' }, expectedAudience: 'openwop-host' });
|
|
113
|
+
if (r === null) return;
|
|
114
|
+
// The load-bearing negative. A host that echoes its input passes every
|
|
115
|
+
// positive assertion; only this distinguishes a verifier from a passthrough.
|
|
116
|
+
expect(
|
|
117
|
+
r.status >= 400,
|
|
118
|
+
driver.describe(
|
|
119
|
+
'RFCS/0154 §A + RFC 0147 R12',
|
|
120
|
+
'an identity minted for another host, accepted here, is how a credential valid elsewhere ' +
|
|
121
|
+
'becomes a credential valid here — the confused-deputy path this profile prevents',
|
|
122
|
+
),
|
|
123
|
+
).toBe(true);
|
|
124
|
+
expect(reasonOf(r.json)).toBe('audience_mismatch');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('an expired delegation is rejected', async () => {
|
|
128
|
+
// §B, gated on §B's own flag. A host doing §A-only identity resolution is
|
|
129
|
+
// not failing this requirement — it has not claimed it.
|
|
130
|
+
if (!behaviorGate(DELEGATION_PROFILE, (await caps())?.delegation?.supported === true)) return;
|
|
131
|
+
const r = await resolve({
|
|
132
|
+
identity: {
|
|
133
|
+
...IDENTITY,
|
|
134
|
+
audience: 'openwop-host',
|
|
135
|
+
delegation: {
|
|
136
|
+
chain: [{ subject: 'spiffe://example/dispatcher' }],
|
|
137
|
+
audience: 'openwop-host',
|
|
138
|
+
expiresAt: '2020-01-01T00:00:00Z',
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
if (r === null) return;
|
|
143
|
+
expect(
|
|
144
|
+
r.status >= 400,
|
|
145
|
+
driver.describe(
|
|
146
|
+
'RFCS/0154 §B',
|
|
147
|
+
'a delegation without a live expiry is a standing grant, which is not what delegation means',
|
|
148
|
+
),
|
|
149
|
+
).toBe(true);
|
|
150
|
+
expect(reasonOf(r.json)).toBe('delegation_expired');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('a failure is non-retriable and carries a closed reason code', async () => {
|
|
154
|
+
if (!behaviorGate(PROFILE, (await caps())?.supported === true)) return;
|
|
155
|
+
const r = await resolve({ identity: { scheme: 'spiffe', subject: 'spiffe://example/unknown' } });
|
|
156
|
+
if (r === null || r.status < 400) return;
|
|
157
|
+
const err = (r.json as { error?: { code?: string; retriable?: boolean } }).error;
|
|
158
|
+
expect(
|
|
159
|
+
err?.retriable,
|
|
160
|
+
driver.describe(
|
|
161
|
+
'spec/v1/host-sample-test-seams.md §20',
|
|
162
|
+
'an identity that does not resolve will not resolve on retry. Marking it retriable invites ' +
|
|
163
|
+
'a caller to hammer a failing authorization path.',
|
|
164
|
+
),
|
|
165
|
+
).toBe(false);
|
|
166
|
+
expect(
|
|
167
|
+
['identity_unverified', 'identity_unresolvable', 'audience_mismatch', 'delegation_expired', 'sender_constraint_missing'],
|
|
168
|
+
driver.describe('RFCS/0154 §A', 'failures use a closed reason vocabulary'),
|
|
169
|
+
).toContain(err?.code);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('a resolution response carries no credential material', async () => {
|
|
173
|
+
if (!behaviorGate(PROFILE, (await caps())?.supported === true)) return;
|
|
174
|
+
const r = await resolve({ identity: { ...IDENTITY, audience: 'openwop-host' } });
|
|
175
|
+
if (r === null) return;
|
|
176
|
+
const serialized = JSON.stringify(r.json ?? {});
|
|
177
|
+
for (const forbidden of ['-----BEGIN', 'Bearer ', 'eyJ']) {
|
|
178
|
+
expect(
|
|
179
|
+
serialized.includes(forbidden),
|
|
180
|
+
driver.describe(
|
|
181
|
+
'RFCS/0154 §A',
|
|
182
|
+
'a verified identity is a fact about a completed verification, not a container for the ' +
|
|
183
|
+
`material that proved it. Found: ${forbidden}`,
|
|
184
|
+
),
|
|
185
|
+
).toBe(false);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|