@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.
- package/CHANGELOG.md +61 -3
- package/README.md +61 -63
- package/api/asyncapi.yaml +54 -38
- package/api/openapi.yaml +34 -6
- package/coverage.md +381 -202
- package/fixtures/connection-packs/connection-pack-github.json +31 -0
- package/fixtures.md +120 -101
- package/package.json +2 -2
- package/schemas/README.md +1 -0
- package/schemas/agent-manifest.schema.json +6 -0
- package/schemas/capabilities.schema.json +75 -2
- package/schemas/connection-pack-manifest.schema.json +161 -0
- package/schemas/orchestrator-decision.schema.json +13 -0
- package/schemas/run-event-payloads.schema.json +23 -6
- package/schemas/run-event.schema.json +12 -2
- package/schemas/run-options.schema.json +1 -2
- package/schemas/run-snapshot.schema.json +2 -1
- package/schemas/suspend-request.schema.json +5 -0
- package/src/scenarios/agent-capability-degraded-projection.test.ts +108 -0
- package/src/scenarios/agent-channel-dispatch.test.ts +16 -8
- package/src/scenarios/agent-requires-capabilities-shape.test.ts +64 -0
- package/src/scenarios/agent-verifier-shape.test.ts +106 -0
- package/src/scenarios/aiproviders-input-shape.test.ts +69 -0
- package/src/scenarios/callai-multimodal.test.ts +86 -0
- package/src/scenarios/connection-pack-manifest-valid.test.ts +122 -0
- package/src/scenarios/connection-pack-no-credential-material.test.ts +125 -0
- package/src/scenarios/connection-pack-reach-exclusive.test.ts +85 -0
- package/src/scenarios/connection-pack-write-reconsent.test.ts +91 -0
- package/src/scenarios/connection-provider-resolution.test.ts +153 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +3 -3
- package/src/scenarios/experimental-tier-shape.test.ts +11 -0
- package/src/scenarios/fixtures-valid.test.ts +34 -0
- package/src/scenarios/grpc-transport.test.ts +108 -0
- package/src/scenarios/i18n-negotiation.test.ts +181 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +2 -2
- package/src/scenarios/media-url-inline-cap.test.ts +5 -3
- package/src/scenarios/spec-corpus-validity.test.ts +129 -4
- package/src/scenarios/stream-text-fixture.test.ts +212 -0
- package/src/scenarios/verifier-gating.test.ts +73 -0
- package/src/scenarios/version-fold.test.ts +193 -0
- package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -2
- 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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
233
|
+
).toBe(pinnedVersion);
|
|
226
234
|
expect(
|
|
227
|
-
fork2Started?.payload.resolvedAgentVersion
|
|
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(
|
|
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
|
+
});
|