@openwop/openwop-conformance 1.18.1 → 1.19.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 +11 -0
- package/README.md +2 -2
- package/api/.redocly.lint-ignore.yaml +22 -0
- package/api/openapi.yaml +13 -4
- package/coverage.md +2 -1
- package/dist/cli.js +235 -4
- package/dist/lib/paths.js +160 -0
- package/dist/lib/profiles.js +461 -0
- package/fixtures/conformance-agent-channel-dispatch.json +27 -0
- package/fixtures.md +15 -0
- package/package.json +1 -1
- package/schemas/README.md +1 -0
- package/schemas/capabilities.schema.json +5 -0
- package/schemas/conformance-certification-bundle.schema.json +86 -0
- package/src/cli.ts +268 -4
- package/src/lib/profiles.ts +85 -0
- package/src/scenarios/agent-channel-dispatch.test.ts +229 -0
- package/src/scenarios/spec-corpus-validity.test.ts +183 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-channel-dispatch (RFC 0082 §B) — PRODUCTION run-graph channel pin +
|
|
3
|
+
* replay reuse, exercised black-box.
|
|
4
|
+
*
|
|
5
|
+
* Complements `agent-deployment-lifecycle.test.ts` Leg 4, which drives the §B
|
|
6
|
+
* pin through the host-sample `deployment-transition` SEAM. This scenario
|
|
7
|
+
* proves the SAME contract from a real run graph (no seam): a canonical
|
|
8
|
+
* `POST /v1/runs` of a workflow whose node binds `agent.channel` MUST
|
|
9
|
+
* (1) resolve the channel to a concrete version at first resolution and
|
|
10
|
+
* record it as `resolvedChannel` + `resolvedAgentVersion` on
|
|
11
|
+
* `agent.invocation.started` (RFC 0077 recorded fact), and
|
|
12
|
+
* (2) on `POST /v1/runs/{runId}:fork {mode:"replay"}` RE-READ that recorded
|
|
13
|
+
* version — and MUST NOT re-resolve a since-moved channel
|
|
14
|
+
* per `agent-deployment.md §B` and `version-negotiation.md`
|
|
15
|
+
* §"Channel resolution + replay determinism". This graduates §B from
|
|
16
|
+
* seam-proven to production-path-proven.
|
|
17
|
+
*
|
|
18
|
+
* Leg 3 (the load-bearing non-re-resolution proof) MOVES the `stable` channel
|
|
19
|
+
* between the original run and a replay fork via the optional deployment
|
|
20
|
+
* seam, then asserts the fork STILL carries the ORIGINAL pin (not the moved
|
|
21
|
+
* version). It self-guards: it runs only when the seam exists AND the move is
|
|
22
|
+
* observable (a fresh run resolves to a different version); otherwise it logs
|
|
23
|
+
* and skips its strict assertion without failing.
|
|
24
|
+
*
|
|
25
|
+
* Gating (root-first per RFC 0073): soft-skips unless the host advertises
|
|
26
|
+
* `agents.deployment.supported:true` AND seeds+advertises the
|
|
27
|
+
* `conformance-agent-channel-dispatch` fixture AND advertises replay mode
|
|
28
|
+
* `replay`. Visible skip by default; hard-fails under
|
|
29
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` (per `lib/behavior-gate.ts`). Hosts that omit
|
|
30
|
+
* `agents.deployment` MUST reject a channel-bearing ref with `validation_error`
|
|
31
|
+
* (`agent-ref.schema.json`) and so cannot seed the fixture — they gate out.
|
|
32
|
+
*
|
|
33
|
+
* Spec references:
|
|
34
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-deployment.md (§B)
|
|
35
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/version-negotiation.md
|
|
36
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0082-agent-deployment-lifecycle.md
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { describe, it, expect } from 'vitest';
|
|
40
|
+
import { driver } from '../lib/driver.js';
|
|
41
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
42
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
43
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
44
|
+
import { readDeploymentCap, driveDeploymentTransition } from '../lib/agentDeployment.js';
|
|
45
|
+
|
|
46
|
+
const FIXTURE_ID = 'conformance-agent-channel-dispatch';
|
|
47
|
+
const BOUND_CHANNEL = 'stable';
|
|
48
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
49
|
+
|
|
50
|
+
interface RunEventDoc {
|
|
51
|
+
eventId: string;
|
|
52
|
+
runId: string;
|
|
53
|
+
type: string;
|
|
54
|
+
payload: Record<string, unknown>;
|
|
55
|
+
sequence: number;
|
|
56
|
+
}
|
|
57
|
+
interface PollEventsResponse {
|
|
58
|
+
events: RunEventDoc[];
|
|
59
|
+
isComplete?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readAllEvents(runId: string): Promise<RunEventDoc[]> {
|
|
63
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0`);
|
|
64
|
+
if (res.status !== 200) return [];
|
|
65
|
+
const body = res.json as PollEventsResponse;
|
|
66
|
+
return body.events ?? [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Advertised replay modes (root-level `replay.modes`, RFC 0073 / profiles.ts). */
|
|
70
|
+
async function fetchReplayModes(): Promise<readonly string[]> {
|
|
71
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
72
|
+
if (res.status !== 200) return [];
|
|
73
|
+
const replay = (res.json as { replay?: { supported?: unknown; modes?: unknown } })?.replay;
|
|
74
|
+
if (replay?.supported !== true || !Array.isArray(replay.modes)) return [];
|
|
75
|
+
return replay.modes.filter((m): m is string => typeof m === 'string');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** First `agent.invocation.started` (by sequence) of a run, or null. */
|
|
79
|
+
async function firstInvocationStarted(runId: string): Promise<RunEventDoc | null> {
|
|
80
|
+
const events = (await readAllEvents(runId))
|
|
81
|
+
.filter((e) => e.type === 'agent.invocation.started')
|
|
82
|
+
.sort((a, b) => a.sequence - b.sequence);
|
|
83
|
+
return events[0] ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Start the channel-bound fixture, wait for terminal, return its runId. */
|
|
87
|
+
async function startChannelRun(): Promise<string> {
|
|
88
|
+
const create = await driver.post('/v1/runs', { workflowId: FIXTURE_ID });
|
|
89
|
+
expect(
|
|
90
|
+
create.status,
|
|
91
|
+
driver.describe(
|
|
92
|
+
'agent-deployment.md §B',
|
|
93
|
+
`a host advertising agents.deployment + the ${FIXTURE_ID} fixture MUST accept a channel-bound run (201)`,
|
|
94
|
+
),
|
|
95
|
+
).toBe(201);
|
|
96
|
+
const runId = (create.json as { runId: string }).runId;
|
|
97
|
+
await pollUntilTerminal(runId, { timeoutMs: 15_000 });
|
|
98
|
+
return runId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe.skipIf(HTTP_SKIP)('agent-channel-dispatch (RFC 0082 §B): production run-graph channel pin + replay reuse', () => {
|
|
102
|
+
it('resolves + records the channel pin on a real run and re-reads it on replay (never re-resolving a moved channel)', async (ctx) => {
|
|
103
|
+
const cap = await readDeploymentCap();
|
|
104
|
+
if (!behaviorGate('openwop-deployment-channel-dispatch', cap?.supported === true)) return;
|
|
105
|
+
if (!isFixtureAdvertised(FIXTURE_ID)) {
|
|
106
|
+
// Host advertises agents.deployment but hasn't seeded the channel-bound
|
|
107
|
+
// fixture — a host-config precondition, not a conformance failure.
|
|
108
|
+
ctx.skip();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const modes = await fetchReplayModes();
|
|
112
|
+
if (!modes.includes('replay')) {
|
|
113
|
+
ctx.skip();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Leg 1: production-path channel resolution + recorded pin (§B) ----
|
|
118
|
+
const sourceRunId = await startChannelRun();
|
|
119
|
+
const started = await firstInvocationStarted(sourceRunId);
|
|
120
|
+
expect(
|
|
121
|
+
started !== null,
|
|
122
|
+
driver.describe(
|
|
123
|
+
'agent-deployment.md §B',
|
|
124
|
+
'a @channel-bound run MUST emit agent.invocation.started',
|
|
125
|
+
),
|
|
126
|
+
).toBe(true);
|
|
127
|
+
expect(
|
|
128
|
+
started!.payload.resolvedChannel === BOUND_CHANNEL,
|
|
129
|
+
driver.describe(
|
|
130
|
+
'agent-deployment.md §B',
|
|
131
|
+
`agent.invocation.started MUST carry the bound channel as resolvedChannel ("${BOUND_CHANNEL}")`,
|
|
132
|
+
),
|
|
133
|
+
).toBe(true);
|
|
134
|
+
const pinnedVersion = started!.payload.resolvedAgentVersion;
|
|
135
|
+
expect(
|
|
136
|
+
typeof pinnedVersion === 'string' && (pinnedVersion as string).length > 0,
|
|
137
|
+
driver.describe(
|
|
138
|
+
'agent-deployment.md §B',
|
|
139
|
+
'a @channel-bound run MUST record a concrete resolvedAgentVersion (the recorded fact a replay re-reads, RFC 0077)',
|
|
140
|
+
),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
|
|
143
|
+
// ---- Leg 2: replay re-reads the recorded version --------------------
|
|
144
|
+
const fork1 = await driver.post(
|
|
145
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
146
|
+
{ fromSeq: 0, mode: 'replay' },
|
|
147
|
+
);
|
|
148
|
+
if (fork1.status === 501) {
|
|
149
|
+
// replay advertised but not implemented for this run — skip-equivalent.
|
|
150
|
+
ctx.skip();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
expect(
|
|
154
|
+
fork1.status,
|
|
155
|
+
driver.describe('rest-endpoints.md POST /v1/runs/{runId}:fork', 'replay fork MUST return 201'),
|
|
156
|
+
).toBe(201);
|
|
157
|
+
const fork1RunId = (fork1.json as { runId: string }).runId;
|
|
158
|
+
await pollUntilTerminal(fork1RunId, { timeoutMs: 15_000 });
|
|
159
|
+
const fork1Started = await firstInvocationStarted(fork1RunId);
|
|
160
|
+
expect(
|
|
161
|
+
fork1Started !== null,
|
|
162
|
+
driver.describe('agent-deployment.md §B', 'a replay fork MUST re-emit agent.invocation.started'),
|
|
163
|
+
).toBe(true);
|
|
164
|
+
expect(
|
|
165
|
+
fork1Started!.payload.resolvedAgentVersion === pinnedVersion,
|
|
166
|
+
driver.describe(
|
|
167
|
+
'agent-deployment.md §B',
|
|
168
|
+
'a replay MUST re-read the recorded resolvedAgentVersion (NOT re-resolve the channel)',
|
|
169
|
+
),
|
|
170
|
+
).toBe(true);
|
|
171
|
+
|
|
172
|
+
// ---- Leg 3 (seam-guarded): move the channel, prove non-re-resolution -
|
|
173
|
+
// The strongest form of §B: after the original pin, MOVE `stable` to a new
|
|
174
|
+
// active version via the optional deployment seam. A replay fork of the
|
|
175
|
+
// ORIGINAL run MUST still carry the ORIGINAL pin — proving the host re-reads
|
|
176
|
+
// the recorded fact rather than re-resolving the (now-moved) channel.
|
|
177
|
+
const moved = await driveDeploymentTransition({
|
|
178
|
+
scenario: 'promote',
|
|
179
|
+
channel: BOUND_CHANNEL,
|
|
180
|
+
});
|
|
181
|
+
if (moved === null) {
|
|
182
|
+
// No deployment-transition seam — Leg 1+2 already give production-path
|
|
183
|
+
// evidence; the cross-move proof needs the seam. Honest skip of Leg 3.
|
|
184
|
+
// eslint-disable-next-line no-console
|
|
185
|
+
console.warn('[agent-channel-dispatch] deployment seam absent — skipping the channel-move non-re-resolution leg (Leg 3)');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// Confirm the move is OBSERVABLE: a fresh channel-bound run must now resolve
|
|
189
|
+
// to a DIFFERENT version. If it doesn't (canary split, no-op promote), we
|
|
190
|
+
// can't prove movement — skip the strict assertion rather than assert falsely.
|
|
191
|
+
const controlRunId = await startChannelRun();
|
|
192
|
+
const controlStarted = await firstInvocationStarted(controlRunId);
|
|
193
|
+
const movedVersion = controlStarted?.payload.resolvedAgentVersion;
|
|
194
|
+
if (typeof movedVersion !== 'string' || movedVersion === pinnedVersion) {
|
|
195
|
+
// eslint-disable-next-line no-console
|
|
196
|
+
console.warn('[agent-channel-dispatch] channel did not observably move — skipping Leg 3 strict assertion');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const fork2 = await driver.post(
|
|
200
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
201
|
+
{ fromSeq: 0, mode: 'replay' },
|
|
202
|
+
);
|
|
203
|
+
if (fork2.status === 501) {
|
|
204
|
+
ctx.skip();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
expect(
|
|
208
|
+
fork2.status,
|
|
209
|
+
driver.describe('rest-endpoints.md POST /v1/runs/{runId}:fork', 'replay fork MUST return 201'),
|
|
210
|
+
).toBe(201);
|
|
211
|
+
const fork2RunId = (fork2.json as { runId: string }).runId;
|
|
212
|
+
await pollUntilTerminal(fork2RunId, { timeoutMs: 15_000 });
|
|
213
|
+
const fork2Started = await firstInvocationStarted(fork2RunId);
|
|
214
|
+
expect(
|
|
215
|
+
fork2Started?.payload.resolvedAgentVersion === pinnedVersion,
|
|
216
|
+
driver.describe(
|
|
217
|
+
'agent-deployment.md §B',
|
|
218
|
+
'after the channel moves, a replay of the original run MUST still carry the ORIGINAL pin — never re-resolving the moved channel',
|
|
219
|
+
),
|
|
220
|
+
).toBe(true);
|
|
221
|
+
expect(
|
|
222
|
+
fork2Started?.payload.resolvedAgentVersion !== movedVersion,
|
|
223
|
+
driver.describe(
|
|
224
|
+
'agent-deployment.md §B',
|
|
225
|
+
'a replay MUST NOT resolve to the post-move version (proves the recorded fact is re-read, not re-resolved)',
|
|
226
|
+
),
|
|
227
|
+
).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
import { describe, it, expect } from 'vitest';
|
|
37
37
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
38
39
|
import { dirname, join, relative, resolve as pathResolve } from 'node:path';
|
|
39
40
|
import Ajv2020 from 'ajv/dist/2020.js';
|
|
40
41
|
import addFormats from 'ajv-formats';
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
TYPESCRIPT_RUN_HELPERS_PATH,
|
|
54
55
|
V1_DIR,
|
|
55
56
|
} from '../lib/paths.js';
|
|
57
|
+
import { verifyBundle, PROFILE_FLOOR_SCENARIOS } from '../lib/profiles.js';
|
|
56
58
|
|
|
57
59
|
// Layout-aware paths come from `lib/paths.ts`. Three layouts:
|
|
58
60
|
// - Repo (github.com/openwop/openwop): schemas/api at repo root,
|
|
@@ -721,6 +723,51 @@ describe('spec-corpus: OpenAPI 3.1 spec is structurally valid', () => {
|
|
|
721
723
|
}
|
|
722
724
|
});
|
|
723
725
|
|
|
726
|
+
// ── Reserved-route disambiguation (RFC 0086/0087 + audit PR #495) ──────────
|
|
727
|
+
// The literal collection routes /v1/agents/roster and /v1/agents/org-chart
|
|
728
|
+
// share a prefix with the parameterized /v1/agents/{agentId}. The mitigation
|
|
729
|
+
// excludes the reserved literals from the {agentId} path param via a
|
|
730
|
+
// negative-lookahead pattern, AND the agent-manifest agentId pattern requires
|
|
731
|
+
// a dotted-tier form the bare literals can't satisfy. These guard both halves
|
|
732
|
+
// from silently regressing (the external standards-readiness audit asked the
|
|
733
|
+
// reserved-route mitigation be bound to a test).
|
|
734
|
+
it('declares both the literal /v1/agents/{roster,org-chart} routes and the {agentId} param route', () => {
|
|
735
|
+
const { raw } = readYamlHeader(openapiPath);
|
|
736
|
+
expect(raw).toContain('/v1/agents/{agentId}:');
|
|
737
|
+
expect(raw).toContain('/v1/agents/roster:');
|
|
738
|
+
expect(raw).toContain('/v1/agents/org-chart:');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('every /v1/agents/{agentId} param excludes the reserved literals (roster, org-chart)', () => {
|
|
742
|
+
const { raw } = readYamlHeader(openapiPath);
|
|
743
|
+
const paramRoutes = (raw.match(/\/v1\/agents\/\{agentId\}/g) ?? []).length;
|
|
744
|
+
const exclusions = (raw.match(/\(\?!roster\$\|org-chart\$\)/g) ?? []).length;
|
|
745
|
+
expect(paramRoutes, 'expected ≥2 /v1/agents/{agentId...} routes (base + /deployments)').toBeGreaterThanOrEqual(2);
|
|
746
|
+
expect(
|
|
747
|
+
exclusions,
|
|
748
|
+
'each /v1/agents/{agentId} param schema MUST exclude the reserved literals via a (?!roster$|org-chart$) lookahead',
|
|
749
|
+
).toBeGreaterThanOrEqual(2);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it('the reserved-literal exclusion pattern rejects roster/org-chart and accepts a real agentId', () => {
|
|
753
|
+
const re = /^(?!roster$|org-chart$).+$/;
|
|
754
|
+
expect(re.test('roster'), 'roster MUST NOT match the {agentId} param').toBe(false);
|
|
755
|
+
expect(re.test('org-chart'), 'org-chart MUST NOT match the {agentId} param').toBe(false);
|
|
756
|
+
expect(re.test('core.example.pack.agent')).toBe(true);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('the agent-manifest agentId pattern can never produce a reserved literal (defense in depth)', () => {
|
|
760
|
+
const manifest = readJson(join(SCHEMAS_DIR, 'agent-manifest.schema.json')) as {
|
|
761
|
+
properties?: { agentId?: { pattern?: string } };
|
|
762
|
+
};
|
|
763
|
+
const pattern = manifest.properties?.agentId?.pattern;
|
|
764
|
+
expect(typeof pattern, 'agent-manifest.schema.json MUST constrain agentId with a pattern').toBe('string');
|
|
765
|
+
const re = new RegExp(pattern as string);
|
|
766
|
+
expect(re.test('roster'), 'manifest agentId MUST NOT permit the reserved literal `roster`').toBe(false);
|
|
767
|
+
expect(re.test('org-chart'), 'manifest agentId MUST NOT permit the reserved literal `org-chart`').toBe(false);
|
|
768
|
+
expect(re.test('core.example.pack.agent')).toBe(true);
|
|
769
|
+
});
|
|
770
|
+
|
|
724
771
|
it('typed error specializations compose the canonical Error schema', () => {
|
|
725
772
|
const { raw } = readYamlHeader(openapiPath);
|
|
726
773
|
|
|
@@ -1351,3 +1398,139 @@ describe.skipIf(FIXTURES_DOC_PATH === null)('spec-corpus: fixtures.json catalog
|
|
|
1351
1398
|
}
|
|
1352
1399
|
});
|
|
1353
1400
|
});
|
|
1401
|
+
|
|
1402
|
+
// RFC 0089 — conformance certification bundle. The schema itself is compiled +
|
|
1403
|
+
// $id-checked by the "JSON Schemas compile under Ajv2020" block above; here we
|
|
1404
|
+
// assert a sample bundle validates AND that the §B binding rule (verifyBundle)
|
|
1405
|
+
// correctly accepts a valid claim and rejects both a not-derivable claim and a
|
|
1406
|
+
// missing-floor-scenario one.
|
|
1407
|
+
describe('spec-corpus: RFC 0089 conformance certification bundle + binding rule', () => {
|
|
1408
|
+
// A discovery document that derives `openwop-core-standard`
|
|
1409
|
+
// (isCore ∧ isInterrupts ∧ a transport — supportedTransports omitted ⇒ rest).
|
|
1410
|
+
const coreStandardDiscovery = {
|
|
1411
|
+
protocolVersion: '1.0',
|
|
1412
|
+
supportedEnvelopes: ['final', 'clarification.request'],
|
|
1413
|
+
schemaVersions: { 'workflow-definition': '1.0' },
|
|
1414
|
+
limits: { clarificationRounds: 3, schemaRounds: 2, envelopesPerTurn: 8 },
|
|
1415
|
+
};
|
|
1416
|
+
const coreStandardFloorPassed = [
|
|
1417
|
+
...PROFILE_FLOOR_SCENARIOS['openwop-core-standard']!.required,
|
|
1418
|
+
'interrupt-resume.test.ts',
|
|
1419
|
+
];
|
|
1420
|
+
const sampleBundle = {
|
|
1421
|
+
bundleVersion: '1',
|
|
1422
|
+
generatedAt: '2026-06-02T00:00:00Z',
|
|
1423
|
+
generator: { name: '@openwop/openwop-conformance --certify', version: '1.18.1' },
|
|
1424
|
+
suite: { package: '@openwop/openwop-conformance', version: '1.18.1' },
|
|
1425
|
+
host: { name: 'openwop-host-sqlite', version: '1.0.0' },
|
|
1426
|
+
discovery: {
|
|
1427
|
+
url: 'https://example.test/.well-known/openwop',
|
|
1428
|
+
sha256: 'a'.repeat(64),
|
|
1429
|
+
document: coreStandardDiscovery,
|
|
1430
|
+
},
|
|
1431
|
+
claimedProfiles: ['openwop-core-standard'],
|
|
1432
|
+
results: {
|
|
1433
|
+
totals: { passed: coreStandardFloorPassed.length, failed: 0, skipped: 0, total: coreStandardFloorPassed.length },
|
|
1434
|
+
passed: coreStandardFloorPassed,
|
|
1435
|
+
failed: [],
|
|
1436
|
+
skipped: [],
|
|
1437
|
+
},
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
1441
|
+
addFormats(ajv);
|
|
1442
|
+
const bundleSchema = readJson(join(SCHEMAS_DIR, 'conformance-certification-bundle.schema.json')) as Record<string, unknown>;
|
|
1443
|
+
|
|
1444
|
+
it('a sample bundle validates against conformance-certification-bundle.schema.json', () => {
|
|
1445
|
+
const validate = ajv.compile(bundleSchema);
|
|
1446
|
+
const ok = validate(sampleBundle);
|
|
1447
|
+
expect(ok, JSON.stringify(validate.errors)).toBe(true);
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
it('verifyBundle ACCEPTS a claim that is discovery-derivable AND floor-proven (§B)', () => {
|
|
1451
|
+
const r = verifyBundle(sampleBundle);
|
|
1452
|
+
expect(r.valid).toBe(true);
|
|
1453
|
+
expect(r.verdicts[0]?.derivable).toBe(true);
|
|
1454
|
+
expect(r.verdicts[0]?.floorProven).toBe(true);
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
it('verifyBundle REJECTS a profile its discovery document does not derive (§B(1))', () => {
|
|
1458
|
+
const notDerivable = {
|
|
1459
|
+
...sampleBundle,
|
|
1460
|
+
discovery: { ...sampleBundle.discovery, document: { ...coreStandardDiscovery, supportedEnvelopes: ['final'] } },
|
|
1461
|
+
};
|
|
1462
|
+
const r = verifyBundle(notDerivable);
|
|
1463
|
+
expect(r.valid).toBe(false);
|
|
1464
|
+
expect(r.verdicts[0]?.derivable).toBe(false);
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it('verifyBundle REJECTS a bundle missing a floor scenario (§B(2))', () => {
|
|
1468
|
+
const missingFloor = {
|
|
1469
|
+
...sampleBundle,
|
|
1470
|
+
results: { ...sampleBundle.results, passed: coreStandardFloorPassed.filter((s) => s !== 'auth.test.ts') },
|
|
1471
|
+
};
|
|
1472
|
+
const r = verifyBundle(missingFloor);
|
|
1473
|
+
expect(r.valid).toBe(false);
|
|
1474
|
+
expect(r.verdicts[0]?.floorProven).toBe(false);
|
|
1475
|
+
expect(r.verdicts[0]?.missingFloor).toContain('auth.test.ts');
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
// RFC 0089 — the committed REAL reference-host bundle, generated by
|
|
1480
|
+
// `openwop-conformance --certify` against the in-memory reference host
|
|
1481
|
+
// (examples/hosts/in-memory). This is the at-`Accepted` "reference host commits
|
|
1482
|
+
// a real generated bundle" evidence: it must (a) validate against the schema and
|
|
1483
|
+
// (b) pass the §B binding rule — every profile it CLAIMS must re-derive from its
|
|
1484
|
+
// own captured discovery document AND be floor-proven. The bundle lives in
|
|
1485
|
+
// `examples/`, which is NOT bundled into the published tarball, so this skips
|
|
1486
|
+
// cleanly under the published layout (V1_DIR === null).
|
|
1487
|
+
describe.skipIf(V1_DIR === null)('spec-corpus: RFC 0089 committed reference-host certification bundle', () => {
|
|
1488
|
+
const repoRoot = V1_DIR === null ? '' : pathResolve(V1_DIR, '..', '..');
|
|
1489
|
+
const bundlePath = join(repoRoot, 'examples', 'hosts', 'in-memory', 'certification-bundle.json');
|
|
1490
|
+
|
|
1491
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
1492
|
+
addFormats(ajv);
|
|
1493
|
+
const bundleSchema = readJson(join(SCHEMAS_DIR, 'conformance-certification-bundle.schema.json')) as Record<
|
|
1494
|
+
string,
|
|
1495
|
+
unknown
|
|
1496
|
+
>;
|
|
1497
|
+
|
|
1498
|
+
it('the committed bundle file exists (generated by --certify)', () => {
|
|
1499
|
+
expect(existsSync(bundlePath), `expected a committed reference bundle at ${bundlePath}`).toBe(true);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
it('the committed reference bundle validates against the bundle schema (§A)', () => {
|
|
1503
|
+
const bundle = readJson(bundlePath);
|
|
1504
|
+
const validate = ajv.compile(bundleSchema);
|
|
1505
|
+
const ok = validate(bundle);
|
|
1506
|
+
expect(ok, JSON.stringify(validate.errors)).toBe(true);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it('verifyBundle ACCEPTS the committed reference bundle — every claimed profile re-derives + is floor-proven (§B)', () => {
|
|
1510
|
+
const bundle = readJson(bundlePath) as Parameters<typeof verifyBundle>[0];
|
|
1511
|
+
const r = verifyBundle(bundle);
|
|
1512
|
+
// The host honestly claims ONLY profiles its discovery document derives, none
|
|
1513
|
+
// of which it fails a floor scenario for — so verifyBundle MUST accept it.
|
|
1514
|
+
const offending = r.verdicts.filter((v) => !v.valid);
|
|
1515
|
+
expect(
|
|
1516
|
+
r.valid,
|
|
1517
|
+
`verifyBundle rejected claimed profile(s): ${JSON.stringify(offending)}`,
|
|
1518
|
+
).toBe(true);
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
it('discovery.sha256 is the canonical-JSON SHA-256 of the captured discovery.document', () => {
|
|
1522
|
+
const bundle = readJson(bundlePath) as {
|
|
1523
|
+
discovery: { sha256: string; document: unknown };
|
|
1524
|
+
};
|
|
1525
|
+
// Mirror the generator's canonical serialization (sorted keys at every level).
|
|
1526
|
+
const canonical = (value: unknown): string => {
|
|
1527
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
1528
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
1529
|
+
const obj = value as Record<string, unknown>;
|
|
1530
|
+
const keys = Object.keys(obj).sort();
|
|
1531
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(',')}}`;
|
|
1532
|
+
};
|
|
1533
|
+
const recomputed = createHash('sha256').update(canonical(bundle.discovery.document)).digest('hex');
|
|
1534
|
+
expect(bundle.discovery.sha256).toBe(recomputed);
|
|
1535
|
+
});
|
|
1536
|
+
});
|