@openwop/openwop-conformance 1.20.0 → 1.23.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +61 -3
  2. package/README.md +61 -63
  3. package/api/asyncapi.yaml +54 -38
  4. package/api/openapi.yaml +34 -6
  5. package/coverage.md +381 -202
  6. package/fixtures/connection-packs/connection-pack-github.json +31 -0
  7. package/fixtures.md +120 -101
  8. package/package.json +2 -2
  9. package/schemas/README.md +1 -0
  10. package/schemas/agent-manifest.schema.json +6 -0
  11. package/schemas/capabilities.schema.json +75 -2
  12. package/schemas/connection-pack-manifest.schema.json +161 -0
  13. package/schemas/orchestrator-decision.schema.json +13 -0
  14. package/schemas/run-event-payloads.schema.json +23 -6
  15. package/schemas/run-event.schema.json +12 -2
  16. package/schemas/run-options.schema.json +1 -2
  17. package/schemas/run-snapshot.schema.json +2 -1
  18. package/schemas/suspend-request.schema.json +5 -0
  19. package/src/scenarios/agent-capability-degraded-projection.test.ts +108 -0
  20. package/src/scenarios/agent-channel-dispatch.test.ts +16 -8
  21. package/src/scenarios/agent-requires-capabilities-shape.test.ts +64 -0
  22. package/src/scenarios/agent-verifier-shape.test.ts +106 -0
  23. package/src/scenarios/aiproviders-input-shape.test.ts +69 -0
  24. package/src/scenarios/callai-multimodal.test.ts +86 -0
  25. package/src/scenarios/connection-pack-manifest-valid.test.ts +122 -0
  26. package/src/scenarios/connection-pack-no-credential-material.test.ts +125 -0
  27. package/src/scenarios/connection-pack-reach-exclusive.test.ts +85 -0
  28. package/src/scenarios/connection-pack-write-reconsent.test.ts +91 -0
  29. package/src/scenarios/connection-provider-resolution.test.ts +153 -0
  30. package/src/scenarios/cross-host-traceparent-propagation.test.ts +3 -3
  31. package/src/scenarios/experimental-tier-shape.test.ts +11 -0
  32. package/src/scenarios/fixtures-valid.test.ts +34 -0
  33. package/src/scenarios/grpc-transport.test.ts +108 -0
  34. package/src/scenarios/i18n-negotiation.test.ts +181 -0
  35. package/src/scenarios/interrupt-token-matrix.test.ts +2 -2
  36. package/src/scenarios/media-url-inline-cap.test.ts +5 -3
  37. package/src/scenarios/spec-corpus-validity.test.ts +129 -4
  38. package/src/scenarios/stream-text-fixture.test.ts +212 -0
  39. package/src/scenarios/verifier-gating.test.ts +73 -0
  40. package/src/scenarios/version-fold.test.ts +193 -0
  41. package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -2
  42. package/src/scenarios/webhook-tenant-isolation.test.ts +184 -0
@@ -129,12 +129,12 @@ describe.skipIf(HTTP_SKIP)('agent-channel-dispatch (RFC 0082 §B): production ru
129
129
  ),
130
130
  ).toBe(true);
131
131
  expect(
132
- started!.payload.resolvedChannel === BOUND_CHANNEL,
132
+ started!.payload.resolvedChannel,
133
133
  driver.describe(
134
134
  'agent-deployment.md §B',
135
135
  `agent.invocation.started MUST carry the bound channel as resolvedChannel ("${BOUND_CHANNEL}")`,
136
136
  ),
137
- ).toBe(true);
137
+ ).toBe(BOUND_CHANNEL);
138
138
  const pinnedVersion = started!.payload.resolvedAgentVersion;
139
139
  expect(
140
140
  typeof pinnedVersion === 'string' && (pinnedVersion as string).length > 0,
@@ -166,18 +166,26 @@ describe.skipIf(HTTP_SKIP)('agent-channel-dispatch (RFC 0082 §B): production ru
166
166
  driver.describe('agent-deployment.md §B', 'a replay fork MUST re-emit agent.invocation.started'),
167
167
  ).toBe(true);
168
168
  expect(
169
- fork1Started!.payload.resolvedAgentVersion === pinnedVersion,
169
+ fork1Started!.payload.resolvedAgentVersion,
170
170
  driver.describe(
171
171
  'agent-deployment.md §B',
172
172
  'a replay MUST re-read the recorded resolvedAgentVersion (NOT re-resolve the channel)',
173
173
  ),
174
- ).toBe(true);
174
+ ).toBe(pinnedVersion);
175
175
 
176
176
  // ---- Leg 3 (seam-guarded): move the channel, prove non-re-resolution -
177
177
  // The strongest form of §B: after the original pin, MOVE `stable` to a new
178
178
  // active version via the optional deployment seam. A replay fork of the
179
179
  // ORIGINAL run MUST still carry the ORIGINAL pin — proving the host re-reads
180
180
  // the recorded fact rather than re-resolving the (now-moved) channel.
181
+ //
182
+ // TEST ISOLATION: this promotes the bound agent's `stable` head via the
183
+ // conformance-only seam and does NOT roll it back — the seam exposes no
184
+ // rollback primitive (see agentDeployment.ts: scenarios are promote /
185
+ // unauthorized / eval-gate-unmet / channel-pin, no `rollback`). Scenarios are
186
+ // independent and the seam is conformance-only (404/403 in production), so the
187
+ // un-restored head is benign; hosts SHOULD nonetheless run the suite against an
188
+ // isolated/ephemeral deployment store rather than shared production state.
181
189
  const moved = await driveDeploymentTransition({
182
190
  scenario: 'promote',
183
191
  agentId: BOUND_AGENT_ID,
@@ -217,18 +225,18 @@ describe.skipIf(HTTP_SKIP)('agent-channel-dispatch (RFC 0082 §B): production ru
217
225
  await pollUntilTerminal(fork2RunId, { timeoutMs: 15_000 });
218
226
  const fork2Started = await firstInvocationStarted(fork2RunId);
219
227
  expect(
220
- fork2Started?.payload.resolvedAgentVersion === pinnedVersion,
228
+ fork2Started?.payload.resolvedAgentVersion,
221
229
  driver.describe(
222
230
  'agent-deployment.md §B',
223
231
  'after the channel moves, a replay of the original run MUST still carry the ORIGINAL pin — never re-resolving the moved channel',
224
232
  ),
225
- ).toBe(true);
233
+ ).toBe(pinnedVersion);
226
234
  expect(
227
- fork2Started?.payload.resolvedAgentVersion !== movedVersion,
235
+ fork2Started?.payload.resolvedAgentVersion,
228
236
  driver.describe(
229
237
  'agent-deployment.md §B',
230
238
  'a replay MUST NOT resolve to the post-move version (proves the recorded fact is re-read, not re-resolved)',
231
239
  ),
232
- ).toBe(true);
240
+ ).not.toBe(movedVersion);
233
241
  });
234
242
  });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Agent-level capability requirements (RFC 0092).
3
+ *
4
+ * Always-on, server-free schema-shape probe of the additive
5
+ * `AgentManifest.requiresCapabilities[]`: a manifest WITH it validates, a
6
+ * manifest WITHOUT it still validates (absent ⇒ no requirements), and a
7
+ * non-string-array value is rejected.
8
+ *
9
+ * The degraded-projection behavior (an unmet key surfaced in the inventory
10
+ * `degraded[]`) is gated on `agents.manifestRuntime` and lands with a reference
11
+ * host (RFC 0092 §Conformance — deferred to Active → Accepted).
12
+ *
13
+ * Spec references:
14
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0092-agent-capability-requirements.md
15
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0072-agent-inventory-and-dispatch.md (the degraded[] marker)
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { readFileSync, readdirSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import Ajv2020 from 'ajv/dist/2020.js';
22
+ import addFormats from 'ajv-formats';
23
+ import { SCHEMAS_DIR } from '../lib/paths.js';
24
+
25
+ const BASE = 'https://openwop.dev/spec/v1/';
26
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
27
+ function loadSchema(name: string): Record<string, unknown> {
28
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
29
+ }
30
+
31
+ describe('agent-requires-capabilities-shape: AgentManifest.requiresCapabilities (RFC 0092 §A, server-free)', () => {
32
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
33
+ addFormats(ajv);
34
+ for (const f of readdirSync(SCHEMAS_DIR)) {
35
+ if (f.endsWith('.schema.json')) {
36
+ try {
37
+ ajv.addSchema(loadSchema(f));
38
+ } catch {
39
+ /* duplicate/ignore */
40
+ }
41
+ }
42
+ }
43
+ const manifest = ajv.getSchema(`${BASE}agent-manifest.schema.json`)!;
44
+
45
+ const base = { agentId: 'core.openwop.agents.demo', persona: 'Demo', modelClass: 'general', systemPrompt: 'do it' };
46
+
47
+ it('a manifest WITH requiresCapabilities validates', () => {
48
+ expect(
49
+ manifest({ ...base, requiresCapabilities: ['host.workspace', 'aiProviders.toolCalling'] }),
50
+ why('RFC 0092 §A', 'a manifest declaring requiresCapabilities MUST validate'),
51
+ ).toBe(true);
52
+ });
53
+
54
+ it('a manifest WITHOUT it still validates (absent ⇒ no requirements)', () => {
55
+ expect(manifest(base), why('RFC 0092 §A', 'requiresCapabilities is optional')).toBe(true);
56
+ });
57
+
58
+ it('rejects a non-string-array value', () => {
59
+ expect(
60
+ manifest({ ...base, requiresCapabilities: [123] }),
61
+ why('RFC 0092 §A', 'requiresCapabilities items MUST be non-empty strings'),
62
+ ).toBe(false);
63
+ });
64
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Agent verifier turn + convergence criteria (RFC 0090).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies:
5
+ * - the `agentVerified` payload $def validates a content-free verdict and
6
+ * rejects an out-of-enum `verdict` and a content-carrying payload
7
+ * (additionalProperties:false — `verifier-no-content-leak`);
8
+ * - `agent.verified` appears in the RunEventType enum;
9
+ * - the `terminate` OrchestratorDecision accepts the additive `successCriteria`;
10
+ * - `capabilities.multiAgent.executionModel` accepts `version: 6` + the
11
+ * `verifier { supported, gating }` sub-block (and rejects version 7).
12
+ *
13
+ * Behavioral assertions (a `verifier.gating` host blocking a merge on `fail`)
14
+ * are gated on `capabilities.multiAgent.executionModel.verifier.gating` and land
15
+ * with a reference host (RFC 0090 §Conformance — deferred to Active → Accepted).
16
+ *
17
+ * Spec references:
18
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0090-agent-verifier-and-convergence.md
19
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/multi-agent-execution.md
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest';
23
+ import { readFileSync, readdirSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import Ajv2020 from 'ajv/dist/2020.js';
26
+ import addFormats from 'ajv-formats';
27
+ import { SCHEMAS_DIR } from '../lib/paths.js';
28
+
29
+ const BASE = 'https://openwop.dev/spec/v1/';
30
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
31
+ function loadSchema(name: string): Record<string, unknown> {
32
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
33
+ }
34
+ /** Register every corpus schema so relative cross-file $refs resolve. */
35
+ function newAjvWithCorpus(): Ajv2020 {
36
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
37
+ addFormats(ajv);
38
+ for (const f of readdirSync(SCHEMAS_DIR)) {
39
+ if (f.endsWith('.schema.json')) {
40
+ try {
41
+ ajv.addSchema(loadSchema(f));
42
+ } catch {
43
+ /* duplicate/ignore */
44
+ }
45
+ }
46
+ }
47
+ return ajv;
48
+ }
49
+
50
+ describe('agent-verifier-shape: agent.verified payload (RFC 0090 §A, server-free)', () => {
51
+ const ajv = newAjvWithCorpus();
52
+ const verified = ajv.getSchema(`${BASE}run-event-payloads.schema.json#/$defs/agentVerified`);
53
+
54
+ it('a conforming content-free verdict validates', () => {
55
+ expect(verified, 'the agentVerified $def MUST exist').toBeTruthy();
56
+ expect(
57
+ verified!({ agentId: 'core.openwop.verifier', target: 'evt-42', verdict: 'pass', criteria: ['grounded'], confidence: 0.9 }),
58
+ why('RFC 0090 §A', 'a conforming agent.verified payload MUST validate'),
59
+ ).toBe(true);
60
+ });
61
+
62
+ it('rejects an out-of-enum verdict', () => {
63
+ expect(verified!({ agentId: 'c', target: 'e', verdict: 'ok' }), why('RFC 0090 §A', 'verdict MUST be pass|fail|revise')).toBe(false);
64
+ });
65
+
66
+ it('rejects a content-carrying payload (verifier-no-content-leak)', () => {
67
+ expect(
68
+ verified!({ agentId: 'c', target: 'e', verdict: 'fail', result: 'the secret answer' }),
69
+ why('RFC 0090 §SECURITY', 'agent.verified MUST be content-free (additionalProperties:false)'),
70
+ ).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe('agent-verifier-shape: RunEventType + terminate + capability (RFC 0090)', () => {
75
+ const ajv = newAjvWithCorpus();
76
+
77
+ it('agent.verified is registered in the RunEventType enum', () => {
78
+ const runEvent = loadSchema('run-event.schema.json') as { $defs?: { RunEventType?: { enum?: string[] } } };
79
+ expect(
80
+ runEvent.$defs?.RunEventType?.enum?.includes('agent.verified'),
81
+ why('RFC 0090 §A', 'agent.verified MUST appear in the RunEventType enum'),
82
+ ).toBe(true);
83
+ });
84
+
85
+ it('the terminate decision accepts the additive successCriteria', () => {
86
+ const decision = ajv.getSchema(`${BASE}orchestrator-decision.schema.json`)!;
87
+ expect(
88
+ decision({ kind: 'terminate', reason: 'goal-reached', successCriteria: [{ key: 'goal-answered', met: true }] }),
89
+ why('RFC 0090 §C', 'terminate MUST accept successCriteria[{key,met}]'),
90
+ ).toBe(true);
91
+ expect(
92
+ decision({ kind: 'terminate', successCriteria: [{ key: 'x' }] }),
93
+ why('RFC 0090 §C', 'a successCriteria entry MUST require both key and met'),
94
+ ).toBe(false);
95
+ });
96
+
97
+ it('capabilities accepts executionModel.version 6 + verifier sub-block', () => {
98
+ const execModel = ajv.getSchema(`${BASE}capabilities.schema.json#/properties/multiAgent/properties/executionModel`);
99
+ expect(execModel, 'the executionModel sub-schema MUST exist').toBeTruthy();
100
+ expect(
101
+ execModel!({ supported: true, version: 6, verifier: { supported: true, gating: true } }),
102
+ why('RFC 0090 §D', 'version:6 + verifier{supported,gating} MUST validate'),
103
+ ).toBe(true);
104
+ expect(execModel!({ supported: true, version: 7 }), why('RFC 0090 §D', 'version above the ceiling MUST be rejected')).toBe(false);
105
+ });
106
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Multimodal perception input (RFC 0091).
3
+ *
4
+ * Always-on, server-free schema-shape probe of the additive
5
+ * `capabilities.aiProviders.input` block: the `modalities` enum is closed
6
+ * (text/image/audio/document) and `maxBytesPerPart` is a positive integer.
7
+ *
8
+ * Behavioral assertions (a host accepting an image ContentPart on callAI and
9
+ * rejecting an unadvertised modality with `unsupported_modality`) are gated on
10
+ * `aiProviders.input.modalities` and land with a reference host
11
+ * (RFC 0091 §Conformance — deferred to Active → Accepted).
12
+ *
13
+ * Spec references:
14
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0091-multimodal-perception-input.md
15
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { readFileSync, readdirSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import Ajv2020 from 'ajv/dist/2020.js';
22
+ import addFormats from 'ajv-formats';
23
+ import { SCHEMAS_DIR } from '../lib/paths.js';
24
+
25
+ const BASE = 'https://openwop.dev/spec/v1/';
26
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
27
+ function loadSchema(name: string): Record<string, unknown> {
28
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
29
+ }
30
+
31
+ describe('aiproviders-input-shape: callAI perception input (RFC 0091 §B, server-free)', () => {
32
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
33
+ addFormats(ajv);
34
+ for (const f of readdirSync(SCHEMAS_DIR)) {
35
+ if (f.endsWith('.schema.json')) {
36
+ try {
37
+ ajv.addSchema(loadSchema(f));
38
+ } catch {
39
+ /* duplicate/ignore */
40
+ }
41
+ }
42
+ }
43
+ const input = ajv.getSchema(`${BASE}capabilities.schema.json#/properties/aiProviders/properties/input`);
44
+
45
+ it('the aiProviders.input sub-schema exists', () => {
46
+ expect(input, 'capabilities.aiProviders.input MUST be declared').toBeTruthy();
47
+ });
48
+
49
+ it('accepts a conforming modalities advertisement', () => {
50
+ expect(
51
+ input!({ modalities: ['text', 'image', 'document'], maxBytesPerPart: 1048576 }),
52
+ why('RFC 0091 §B', 'a conforming input advertisement MUST validate'),
53
+ ).toBe(true);
54
+ });
55
+
56
+ it('rejects an out-of-enum modality', () => {
57
+ expect(
58
+ input!({ modalities: ['text', 'video'] }),
59
+ why('RFC 0091 §B', 'modalities is a closed enum (text/image/audio/document)'),
60
+ ).toBe(false);
61
+ });
62
+
63
+ it('rejects a non-positive maxBytesPerPart', () => {
64
+ expect(
65
+ input!({ modalities: ['text'], maxBytesPerPart: 0 }),
66
+ why('RFC 0091 §B', 'maxBytesPerPart MUST be a positive integer'),
67
+ ).toBe(false);
68
+ });
69
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Multimodal perception input on callAI (RFC 0091 §A/§B) — behavioral.
3
+ *
4
+ * Gated on `capabilities.aiProviders.input.modalities` including a non-text
5
+ * modality (root-first per RFC 0073). Soft-skips when unadvertised (default) /
6
+ * hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true`. The always-on wire-shape
7
+ * coverage lives in `aiproviders-input-shape.test.ts`; this asserts host
8
+ * BEHAVIOR via the documented host-sample callAI seam
9
+ * `POST /v1/host/sample/ai/call` (soft-skips on 404 until a host wires it):
10
+ *
11
+ * - an ADVERTISED non-text modality (e.g. an `image` ContentPart) is ACCEPTED
12
+ * (not rejected as unsupported);
13
+ * - an UNADVERTISED modality MUST be rejected with the canonical
14
+ * `unsupported_modality` error — never silently dropped (RFC 0091 §A).
15
+ *
16
+ * Spec references:
17
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0091-multimodal-perception-input.md
18
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md (§host.aiProviders)
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver } from '../lib/driver.js';
23
+ import { behaviorGate } from '../lib/behavior-gate.js';
24
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
25
+
26
+ const ALL_MODALITIES = ['text', 'image', 'audio', 'document'];
27
+
28
+ /** Read the canonical error code from a seam response body (tolerant of
29
+ * `{error}` / `{code}` / `{error:{code}}` shapes). */
30
+ function errCode(json: unknown): string | undefined {
31
+ const j = json as { error?: unknown; code?: unknown };
32
+ if (typeof j?.code === 'string') return j.code;
33
+ if (typeof j?.error === 'string') return j.error;
34
+ const e = j?.error as { code?: unknown } | undefined;
35
+ if (e && typeof e.code === 'string') return e.code;
36
+ return undefined;
37
+ }
38
+
39
+ const SEAM = '/v1/host/sample/ai/call';
40
+
41
+ describe('callai-multimodal (RFC 0091 §A/§B)', () => {
42
+ it('accepts an advertised non-text modality and rejects an unadvertised one with unsupported_modality', async () => {
43
+ const ai = await readCapabilityFamily<Record<string, unknown>>('aiProviders');
44
+ const input = ai?.input as { modalities?: unknown } | undefined;
45
+ const modalities = Array.isArray(input?.modalities) ? (input!.modalities as string[]) : [];
46
+ const advertisedNonText = modalities.filter((m) => m !== 'text');
47
+ // Gate on advertising at least one non-text input modality.
48
+ if (!behaviorGate('openwop-callai-multimodal', advertisedNonText.length > 0)) return;
49
+
50
+ const accepted = advertisedNonText[0]!; // an advertised non-text modality
51
+ const unadvertised = ALL_MODALITIES.find((m) => m !== 'text' && !modalities.includes(m));
52
+
53
+ // 1) An advertised modality part is ACCEPTED (not an unsupported_modality refusal).
54
+ const okPart =
55
+ accepted === 'image'
56
+ ? { type: 'image', mimeType: 'image/png', dataBase64: 'iVBORw0KGgo=' }
57
+ : accepted === 'audio'
58
+ ? { type: 'audio', mimeType: 'audio/mp3', dataBase64: 'AAAA' }
59
+ : { type: 'file', mimeType: 'application/pdf', dataBase64: 'JVBERi0=' };
60
+ const okRes = await driver.post(SEAM, {
61
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'describe this' }, okPart] }],
62
+ });
63
+ if (okRes.status === 404) return; // seam unwired — soft-skip the whole behavioral suite
64
+ expect(
65
+ errCode(okRes.json) !== 'unsupported_modality',
66
+ driver.describe('RFC 0091 §B', `an advertised modality (${accepted}) MUST NOT be rejected as unsupported_modality`),
67
+ ).toBe(true);
68
+
69
+ // 2) An unadvertised modality MUST be rejected with unsupported_modality.
70
+ if (unadvertised) {
71
+ const badPart =
72
+ unadvertised === 'audio'
73
+ ? { type: 'audio', mimeType: 'audio/mp3', dataBase64: 'AAAA' }
74
+ : unadvertised === 'document'
75
+ ? { type: 'file', mimeType: 'application/pdf', dataBase64: 'JVBERi0=' }
76
+ : { type: 'image', mimeType: 'image/png', dataBase64: 'iVBORw0KGgo=' };
77
+ const badRes = await driver.post(SEAM, {
78
+ messages: [{ role: 'user', content: [badPart] }],
79
+ });
80
+ expect(
81
+ errCode(badRes.json) === 'unsupported_modality',
82
+ driver.describe('RFC 0091 §A', `an unadvertised modality (${unadvertised}) MUST be rejected with unsupported_modality (never silently dropped)`),
83
+ ).toBe(true);
84
+ }
85
+ });
86
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Connection-pack manifest validity — `connection-packs.md` §Manifest clauses 1/3
3
+ * + `schemas/connection-pack-manifest.schema.json` (RFC 0095 §A).
4
+ *
5
+ * Always-on, server-free schema probe. Exercises the new
6
+ * `connection-pack-manifest.schema.json` with the canonical positive fixture
7
+ * and the kind-discriminator negatives:
8
+ *
9
+ * 1. Positive: the `connection-pack-github` fixture (a complete `kind:
10
+ * "connection"` manifest, MCP reach) validates cleanly.
11
+ * 2. Capability shape: `capabilities.schema.json` declares
12
+ * `connections.packsSupported` (RFC 0095 §C).
13
+ * 3. Negative — kind discriminator: the same manifest with `kind: "node"`
14
+ * is rejected (`const` violation) — the discriminator routes a
15
+ * connection manifest away from the other pack schemas.
16
+ * 4. Negative — kind/contents mixing: a manifest carrying BOTH `provider`
17
+ * AND `nodes[]` is rejected. Surface-level outcome at the registry is
18
+ * `pack_kind_invalid` per `node-packs.md` §"Pack kinds"; schema-level
19
+ * outcome is an `additionalProperties` violation on `nodes`.
20
+ * 5. Negative — non-https token endpoint: `http://` is rejected with a
21
+ * `pattern` violation (clause 3).
22
+ * 6. Positive — a SemVer prerelease `version` (`1.0.0-alpha.1`) is
23
+ * schema-VALID: prerelease *precedence* (clause 6, SemVer §11) is a
24
+ * host resolution concern, not a manifest-shape constraint.
25
+ *
26
+ * Behavioral resolution legs live in `connection-provider-resolution.test.ts`
27
+ * (capability-gated on `capabilities.connections.packsSupported`).
28
+ *
29
+ * @see spec/v1/connection-packs.md
30
+ * @see schemas/connection-pack-manifest.schema.json
31
+ * @see RFCS/0095-connection-packs-portable-provider-definitions.md
32
+ */
33
+
34
+ import { describe, it, expect } from 'vitest';
35
+ import { readFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import Ajv2020 from 'ajv/dist/2020.js';
38
+ import addFormats from 'ajv-formats';
39
+ import type { ErrorObject } from 'ajv';
40
+ import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
41
+
42
+ const SCHEMA_PATH = join(SCHEMAS_DIR, 'connection-pack-manifest.schema.json');
43
+ const FIXTURE_PATH = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-github.json');
44
+
45
+ type Manifest = Record<string, unknown> & {
46
+ provider: Record<string, unknown> & { auth: Record<string, unknown> };
47
+ };
48
+
49
+ function fixture(): Manifest {
50
+ return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Manifest;
51
+ }
52
+
53
+ describe('category: connection-pack manifest validation (RFC 0095 §A)', () => {
54
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
55
+ addFormats(ajv);
56
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
57
+ const validate = ajv.compile(schema);
58
+
59
+ const failsWith = (manifest: unknown, keyword: string): ErrorObject[] => {
60
+ const ok = validate(manifest);
61
+ expect(ok).toBe(false);
62
+ return (validate.errors ?? []).filter((e) => e.keyword === keyword);
63
+ };
64
+
65
+ it('positive: the connection-pack-github fixture validates cleanly', () => {
66
+ expect(
67
+ validate(fixture()),
68
+ `connection-packs.md §Manifest clause 1: a well-formed kind:"connection" manifest MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
69
+ ).toBe(true);
70
+ });
71
+
72
+ it('capabilities.schema.json declares connections.packsSupported (RFC 0095 §C)', () => {
73
+ const caps = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8')) as {
74
+ properties?: Record<string, { properties?: Record<string, unknown>; required?: string[] }>;
75
+ };
76
+ const connections = caps.properties?.connections;
77
+ expect(connections, 'capabilities.md §connections — the connections block MUST be declared').toBeDefined();
78
+ expect(
79
+ connections?.properties?.packsSupported,
80
+ 'RFC 0095 §C — connections.packsSupported MUST be declared',
81
+ ).toBeDefined();
82
+ });
83
+
84
+ it('negative: the kind discriminator routes other kinds away (kind: "node" rejected)', () => {
85
+ const m = { ...fixture(), kind: 'node' };
86
+ const errs = failsWith(m, 'const');
87
+ expect(
88
+ errs.length,
89
+ 'connection-packs.md §Manifest clause 1: kind MUST be the const "connection"',
90
+ ).toBeGreaterThan(0);
91
+ });
92
+
93
+ it('negative: a manifest mixing provider and nodes[] is rejected (pack_kind_invalid at the registry)', () => {
94
+ const m = {
95
+ ...fixture(),
96
+ nodes: [{ typeId: 'vendor.acme.x', version: '1.0.0', category: 'data', role: 'pure' }],
97
+ };
98
+ const errs = failsWith(m, 'additionalProperties');
99
+ expect(
100
+ errs.some((e) => (e.params as { additionalProperty?: string }).additionalProperty === 'nodes'),
101
+ 'node-packs.md §"Pack kinds": one kind per pack — a foreign `nodes[]` field MUST be rejected (additionalProperties:false)',
102
+ ).toBe(true);
103
+ });
104
+
105
+ it('negative: a non-https token endpoint is rejected (clause 3)', () => {
106
+ const m = fixture();
107
+ (m.provider.auth.endpoints as Record<string, string>).token = 'http://example.com/token';
108
+ const errs = failsWith(m, 'pattern');
109
+ expect(
110
+ errs.length,
111
+ 'connection-packs.md §Manifest clause 3: auth endpoints MUST be absolute https:// URLs',
112
+ ).toBeGreaterThan(0);
113
+ });
114
+
115
+ it('positive: a SemVer prerelease version is schema-valid (precedence is a host concern, clause 6)', () => {
116
+ const m = { ...fixture(), version: '1.0.0-alpha.1' };
117
+ expect(
118
+ validate(m),
119
+ `connection-packs.md §Manifest clause 6: prerelease ordering is resolution-time SemVer §11, not manifest shape. Errors: ${JSON.stringify(validate.errors)}`,
120
+ ).toBe(true);
121
+ });
122
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Connection packs carry NO credential material — `connection-packs.md`
3
+ * §Manifest clause 2 (RFC 0095 §B.2). Public test for the protocol-tier
4
+ * SECURITY invariant `connection-pack-no-credential-material`.
5
+ *
6
+ * Two layers:
7
+ *
8
+ * A. Always-on, server-free schema probe — every name on the normative
9
+ * minimum blocklist (`clientSecret`, `client_secret`, `apiKey`,
10
+ * `api_key`, `token`, `accessToken`, `refreshToken`, `password`,
11
+ * `privateKey`, `secret`), injected at the manifest root, under
12
+ * `provider`, and under `provider.auth`, is rejected by
13
+ * `connection-pack-manifest.schema.json` (`additionalProperties:false`
14
+ * everywhere — the schema layer never admits a secret-named field).
15
+ * The single normative EXEMPTION — the property named `token` at
16
+ * exactly `provider.auth.endpoints.token` (the OAuth token-endpoint
17
+ * URL) — IS schema-valid.
18
+ *
19
+ * B. Capability-gated behavioral leg — on a host advertising
20
+ * `capabilities.connections.packsSupported: true` that exposes the
21
+ * `POST /v1/host/sample/connection-packs/install` test seam
22
+ * (`host-sample-test-seams.md`), installing a manifest that carries
23
+ * `clientSecret` MUST be rejected with the SPECIFIC error code
24
+ * `connection_pack_credential_material` — not a generic schema-shape
25
+ * error — because clause 2 requires the credential-material scan to
26
+ * run BEFORE generic schema validation. Hosts without the seam
27
+ * soft-skip (404); unadvertised hosts skip via the behavior gate.
28
+ *
29
+ * @see spec/v1/connection-packs.md
30
+ * @see SECURITY/invariants.yaml id: connection-pack-no-credential-material
31
+ * @see RFCS/0095-connection-packs-portable-provider-definitions.md
32
+ */
33
+
34
+ import { describe, it, expect } from 'vitest';
35
+ import { readFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import Ajv2020 from 'ajv/dist/2020.js';
38
+ import addFormats from 'ajv-formats';
39
+ import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
40
+ import { driver } from '../lib/driver.js';
41
+ import { behaviorGate } from '../lib/behavior-gate.js';
42
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
43
+
44
+ const SCHEMA_PATH = join(SCHEMAS_DIR, 'connection-pack-manifest.schema.json');
45
+ const FIXTURE_PATH = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-github.json');
46
+
47
+ const BLOCKLIST = [
48
+ 'clientSecret',
49
+ 'client_secret',
50
+ 'apiKey',
51
+ 'api_key',
52
+ 'token',
53
+ 'accessToken',
54
+ 'refreshToken',
55
+ 'password',
56
+ 'privateKey',
57
+ 'secret',
58
+ ] as const;
59
+
60
+ type Manifest = Record<string, unknown> & {
61
+ provider: Record<string, unknown> & { auth: Record<string, unknown> };
62
+ };
63
+
64
+ function fixture(): Manifest {
65
+ return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Manifest;
66
+ }
67
+
68
+ describe('connection-pack-no-credential-material: schema layer (always-on, server-free)', () => {
69
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
70
+ addFormats(ajv);
71
+ const validate = ajv.compile(JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')));
72
+
73
+ it('every blocklisted property name is schema-rejected at the root, provider, and auth levels', () => {
74
+ for (const name of BLOCKLIST) {
75
+ const atRoot = { ...fixture(), [name]: 'xxx' };
76
+ const atProvider = fixture();
77
+ (atProvider.provider as Record<string, unknown>)[name] = 'xxx';
78
+ const atAuth = fixture();
79
+ (atAuth.provider.auth as Record<string, unknown>)[name] = 'xxx';
80
+ for (const [where, m] of [['root', atRoot], ['provider', atProvider], ['provider.auth', atAuth]] as const) {
81
+ expect(
82
+ validate(m),
83
+ `SECURITY invariant connection-pack-no-credential-material — a property named "${name}" at ${where} MUST NOT validate (additionalProperties:false)`,
84
+ ).toBe(false);
85
+ }
86
+ }
87
+ });
88
+
89
+ it('the exemption: provider.auth.endpoints.token (the token-endpoint URL) IS valid', () => {
90
+ const m = fixture();
91
+ expect(
92
+ typeof (m.provider.auth.endpoints as Record<string, string>).token,
93
+ 'fixture sanity — the github fixture declares the token endpoint',
94
+ ).toBe('string');
95
+ expect(
96
+ validate(m),
97
+ `connection-packs.md §Manifest clause 2 — the property named "token" at exactly provider.auth.endpoints.token is the OAuth token-endpoint URL and MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
98
+ ).toBe(true);
99
+ });
100
+ });
101
+
102
+ describe('connection-pack-no-credential-material: specific rejection code (capability-gated, RFC 0095 §B.2)', () => {
103
+ it('installing a manifest carrying clientSecret is rejected with connection_pack_credential_material', async () => {
104
+ const connections = await readCapabilityFamily<{ packsSupported?: boolean }>('connections');
105
+ if (!behaviorGate('connections.packsSupported', connections?.packsSupported === true)) return;
106
+
107
+ const leaky = fixture();
108
+ (leaky.provider.auth as Record<string, unknown>).clientSecret = 'ghs_conformance_canary';
109
+ const res = await driver.post('/v1/host/sample/connection-packs/install', { manifest: leaky });
110
+ if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
111
+
112
+ const body = res.json as { installed?: boolean; errors?: Array<{ code?: string }> } | undefined;
113
+ expect(
114
+ body?.installed,
115
+ driver.describe('connection-packs.md §Manifest clause 2', 'a manifest carrying credential material MUST NOT install'),
116
+ ).toBe(false);
117
+ expect(
118
+ (body?.errors ?? []).some((e) => e.code === 'connection_pack_credential_material'),
119
+ driver.describe(
120
+ 'connection-packs.md §Manifest clause 2',
121
+ 'the credential-material scan runs BEFORE generic schema validation — the SPECIFIC code connection_pack_credential_material MUST surface, not a generic shape error',
122
+ ),
123
+ ).toBe(true);
124
+ });
125
+ });