@openwop/openwop-conformance 1.6.1 → 1.11.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 +44 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +127 -0
- package/api/openapi.yaml +518 -1
- package/coverage.md +44 -2
- package/fixtures/conformance-run-duration-breach.json +33 -0
- package/fixtures/oauth-providers/synthetic.json +38 -0
- package/fixtures.md +29 -0
- package/package.json +1 -1
- package/schemas/README.md +22 -0
- package/schemas/agent-deployment-transition.schema.json +49 -0
- package/schemas/agent-deployment.schema.json +54 -0
- package/schemas/agent-eval-suite.schema.json +140 -0
- package/schemas/agent-inventory-response.schema.json +115 -0
- package/schemas/agent-manifest.schema.json +5 -0
- package/schemas/agent-org-chart.schema.json +82 -0
- package/schemas/agent-ref.schema.json +12 -2
- package/schemas/agent-roster-entry.schema.json +81 -0
- package/schemas/agent-roster-response.schema.json +21 -0
- package/schemas/ai-envelope.schema.json +28 -0
- package/schemas/artifact-type-pack-manifest.schema.json +160 -0
- package/schemas/budget-policy.schema.json +18 -0
- package/schemas/capabilities.schema.json +448 -4
- package/schemas/chat-card-pack-manifest.schema.json +158 -0
- package/schemas/credential-provenance.schema.json +18 -0
- package/schemas/envelopes/media.audio.schema.json +38 -0
- package/schemas/envelopes/media.file.schema.json +37 -0
- package/schemas/envelopes/media.image.schema.json +33 -0
- package/schemas/eval-summary.schema.json +92 -0
- package/schemas/heartbeat-evaluated.schema.json +14 -0
- package/schemas/heartbeat-state-changed.schema.json +14 -0
- package/schemas/node-pack-manifest.schema.json +33 -1
- package/schemas/org-chart-responsibility-view.schema.json +26 -0
- package/schemas/run-event-payloads.schema.json +380 -6
- package/schemas/run-event.schema.json +23 -0
- package/schemas/tool-descriptor.schema.json +63 -0
- package/schemas/trigger-subscription.schema.json +26 -0
- package/schemas/workflow-definition.schema.json +5 -0
- package/schemas/workspace-file-create.schema.json +20 -0
- package/schemas/workspace-file.schema.json +39 -0
- package/src/lib/agentLoop.ts +44 -0
- package/src/lib/agentRoster.ts +76 -0
- package/src/lib/agentRuntime.ts +45 -0
- package/src/lib/artifactTypes.ts +96 -0
- package/src/lib/cardPacks.ts +52 -0
- package/src/lib/discovery-capabilities.ts +50 -0
- package/src/lib/distillation.ts +38 -0
- package/src/lib/feedback.ts +3 -3
- package/src/lib/heartbeat.ts +31 -0
- package/src/lib/liveRuntime.ts +59 -0
- package/src/lib/memoryAttribution.ts +48 -0
- package/src/lib/profiles.ts +157 -0
- package/src/lib/runtimeRequires.ts +38 -0
- package/src/lib/safeFetch.ts +87 -0
- package/src/lib/subRunAttestation.ts +35 -0
- package/src/lib/toolHooks.ts +33 -0
- package/src/scenarios/agent-deployment-shape.test.ts +139 -0
- package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
- package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
- package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
- package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
- package/src/scenarios/agent-live-structured-output.test.ts +58 -0
- package/src/scenarios/agent-loop-iteration-monotonic.test.ts +33 -0
- package/src/scenarios/agent-loop-stateful-resume.test.ts +28 -0
- package/src/scenarios/agent-loop-version5-shape.test.ts +41 -0
- package/src/scenarios/agent-loop-workspace-snapshot.test.ts +33 -0
- package/src/scenarios/agent-manifest-runtime.test.ts +85 -0
- package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
- package/src/scenarios/agent-platform-profile.test.ts +158 -0
- package/src/scenarios/agent-roster-attribution.test.ts +179 -0
- package/src/scenarios/agent-roster-shape.test.ts +146 -0
- package/src/scenarios/ai-envelope-shape.test.ts +14 -18
- package/src/scenarios/aiEnvelope.capBreached.test.ts +2 -1
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +2 -1
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +2 -1
- package/src/scenarios/approval-gate-flow.test.ts +4 -6
- package/src/scenarios/artifact-schema-compile-bounded.test.ts +126 -0
- package/src/scenarios/artifact-type-pack-install.test.ts +78 -0
- package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +140 -0
- package/src/scenarios/artifact-type-store-without-render.test.ts +54 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -2
- package/src/scenarios/auth-api-key-rotation.test.ts +2 -1
- package/src/scenarios/auth-mtls.test.ts +2 -1
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +2 -1
- package/src/scenarios/auth-oidc-user-bearer.test.ts +2 -1
- package/src/scenarios/auth-saml-profile.test.ts +2 -1
- package/src/scenarios/auth-scim-profile.test.ts +2 -1
- package/src/scenarios/authorization-fail-closed.test.ts +2 -1
- package/src/scenarios/authorization-roles-shape.test.ts +2 -1
- package/src/scenarios/budget-policy-shape.test.ts +136 -0
- package/src/scenarios/byok-auth-modes.test.ts +141 -0
- package/src/scenarios/chat-card-pack-execution.test.ts +56 -0
- package/src/scenarios/chat-card-pack-manifest-validation.test.ts +128 -0
- package/src/scenarios/commitment-fired.test.ts +83 -0
- package/src/scenarios/credential-payload-redaction.test.ts +2 -1
- package/src/scenarios/credentials-capability-shape.test.ts +2 -1
- package/src/scenarios/cross-engine-append-ordering.test.ts +2 -1
- package/src/scenarios/cross-host-ancestry-endpoint.test.ts +3 -2
- package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
- package/src/scenarios/deadletter-capability-shape.test.ts +2 -1
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +2 -1
- package/src/scenarios/distillation-index-roundtrip.test.ts +35 -0
- package/src/scenarios/distillation-secret-carryforward.test.ts +35 -0
- package/src/scenarios/distillation-shape.test.ts +41 -0
- package/src/scenarios/distillation-stable-archive.test.ts +37 -0
- package/src/scenarios/distillation-token-budget.test.ts +45 -0
- package/src/scenarios/egress-provenance-shape.test.ts +137 -0
- package/src/scenarios/envelope-completion-distinguishes-truncation.test.ts +4 -3
- package/src/scenarios/envelope-reasoning-secret-redaction.test.ts +5 -4
- package/src/scenarios/envelope-reasoning-shape.test.ts +3 -2
- package/src/scenarios/envelope-refusal-shape.test.ts +3 -2
- package/src/scenarios/envelope-rendering-hint.test.ts +95 -0
- package/src/scenarios/envelope-retry-attempted.test.ts +2 -1
- package/src/scenarios/envelope-tier-one-subset-static.test.ts +3 -2
- package/src/scenarios/exec-not-protocol-tier.test.ts +137 -0
- package/src/scenarios/experimental-tier-shape.test.ts +5 -4
- package/src/scenarios/fs-path-traversal.test.ts +2 -1
- package/src/scenarios/heartbeat-capability-shape.test.ts +35 -0
- package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +28 -0
- package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +43 -0
- package/src/scenarios/heartbeat-runtime-bound.test.ts +30 -0
- package/src/scenarios/http-client-ssrf.test.ts +10 -13
- package/src/scenarios/mcp-toolcall-redaction.test.ts +3 -2
- package/src/scenarios/media-url-inline-cap.test.ts +167 -0
- package/src/scenarios/memory-attribution-emits-on-write.test.ts +54 -0
- package/src/scenarios/memory-attribution-no-content.test.ts +45 -0
- package/src/scenarios/memory-attribution-replay-stable.test.ts +60 -0
- package/src/scenarios/memory-attribution-shape.test.ts +28 -0
- package/src/scenarios/memory-attribution-tenant-scoped.test.ts +44 -0
- package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
- package/src/scenarios/memory-compaction-event-emitted.test.ts +2 -1
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +2 -1
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +2 -1
- package/src/scenarios/memory-consolidation-idempotent.test.ts +77 -0
- package/src/scenarios/memory-consolidation-shape.test.ts +90 -0
- package/src/scenarios/model-capability-substituted.test.ts +2 -1
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
- package/src/scenarios/multi-agent-handoff-state-machine.test.ts +6 -5
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +4 -3
- package/src/scenarios/multi-region-idempotency.test.ts +10 -10
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
- package/src/scenarios/oauth-capability-shape.test.ts +2 -1
- package/src/scenarios/oauth-connector-redaction.test.ts +2 -1
- package/src/scenarios/pause-resume.test.ts +3 -3
- package/src/scenarios/production-backpressure.test.ts +2 -2
- package/src/scenarios/production-retention-expiry.test.ts +2 -2
- package/src/scenarios/prompt-all-four-kinds-events.test.ts +2 -1
- package/src/scenarios/prompt-composed-secret-redaction.test.ts +2 -1
- package/src/scenarios/prompt-composed-trust-marker.test.ts +2 -1
- package/src/scenarios/prompt-end-to-end-events.test.ts +2 -1
- package/src/scenarios/prompt-list-and-fetch.test.ts +2 -1
- package/src/scenarios/prompt-mutable-lifecycle.test.ts +2 -1
- package/src/scenarios/prompt-mutation-workspace-membership-enforced.test.ts +2 -1
- package/src/scenarios/prompt-pack-install.test.ts +2 -1
- package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +2 -1
- package/src/scenarios/prompt-render-deterministic.test.ts +2 -1
- package/src/scenarios/prompt-resolution-chain-agent-intrinsic.test.ts +2 -1
- package/src/scenarios/prompt-resolution-chain-fallback-cascade.test.ts +2 -1
- package/src/scenarios/prompt-resolution-chain-node-wins.test.ts +2 -1
- package/src/scenarios/prompt-template-shape.test.ts +2 -1
- package/src/scenarios/provider-usage.test.ts +2 -1
- package/src/scenarios/replay-divergence-at-refusal.test.ts +4 -3
- package/src/scenarios/replay-fork-arbitrary.test.ts +3 -1
- package/src/scenarios/replay-llm-cache-key-portable.test.ts +2 -1
- package/src/scenarios/replayDeterminism.test.ts +3 -1
- package/src/scenarios/run-execution-bounds-shape.test.ts +133 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
- package/src/scenarios/runtime-requires-shape.test.ts +134 -0
- package/src/scenarios/safefetch-behavior.test.ts +99 -0
- package/src/scenarios/safefetch-live-audit.test.ts +175 -0
- package/src/scenarios/sandbox-memory-cap.test.ts +2 -1
- package/src/scenarios/sandbox-mvp-behavior.test.ts +2 -1
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +2 -1
- package/src/scenarios/scheduling-capability-shape.test.ts +2 -1
- package/src/scenarios/scheduling-cron-fires-once.test.ts +2 -1
- package/src/scenarios/secret-leakage-otel-attribute.test.ts +7 -6
- package/src/scenarios/spec-corpus-validity.test.ts +20 -4
- package/src/scenarios/subrun-approval-fail-closed.test.ts +33 -0
- package/src/scenarios/subrun-approval-gate.test.ts +35 -0
- package/src/scenarios/subrun-attestation-shape.test.ts +30 -0
- package/src/scenarios/subrun-checksum-stable.test.ts +43 -0
- package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
- package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +39 -0
- package/src/scenarios/tool-hooks-content-free.test.ts +40 -0
- package/src/scenarios/tool-hooks-rate-limit.test.ts +32 -0
- package/src/scenarios/tool-hooks-secret-redaction.test.ts +34 -0
- package/src/scenarios/tool-hooks-shape.test.ts +34 -0
- package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +3 -10
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +2 -2
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +2 -2
- package/src/scenarios/wasm-pack-load.test.ts +2 -2
- package/src/scenarios/wasm-pack-memory-cap.test.ts +3 -6
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +2 -2
- package/src/scenarios/workflow-primary-output-annotation.test.ts +142 -0
- package/src/scenarios/workspace-behavior.test.ts +134 -0
- package/src/scenarios/workspace-capability-shape.test.ts +73 -0
- package/src/scenarios/workspace-cross-tenant-isolation.test.ts +84 -0
- package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standing agent roster — entry + capability + attribution-event shapes (RFC 0086).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free schema-shape probe. Verifies that:
|
|
5
|
+
* - `capabilities.agents.roster` is declared with its `supported` /
|
|
6
|
+
* `installScope` / `portfolioTriggerSources` sub-flags.
|
|
7
|
+
* - `agent-roster-entry.schema.json` compiles and round-trips a conforming
|
|
8
|
+
* entry, and rejects malformed ones (a non-`host:` rosterId; an `agentRef`
|
|
9
|
+
* carrying BOTH `version` and `channel` — the RFC 0082 §A XOR rule).
|
|
10
|
+
* - the `roster.run.initiated` payload $def validates a conforming
|
|
11
|
+
* content-free attribution record and requires its ids + persona.
|
|
12
|
+
* - `roster.run.initiated` is CONTENT-FREE: a payload carrying a work-item
|
|
13
|
+
* `body` or a `prompt` is rejected (`additionalProperties:false`). This is
|
|
14
|
+
* the public test for the protocol-tier SECURITY invariant
|
|
15
|
+
* `roster-attribution-no-content`.
|
|
16
|
+
* - the `AgentInventoryEntry` carries the additive optional `roster`
|
|
17
|
+
* portfolio projection (RFC 0086 §B).
|
|
18
|
+
* - `roster.run.initiated` appears in the RunEventType enum.
|
|
19
|
+
*
|
|
20
|
+
* Behavioral assertions (a scheduled portfolio fire emitting roster.run.initiated
|
|
21
|
+
* before agent.invocation.started; the work-item causation chain; the replay
|
|
22
|
+
* re-read; cross-tenant 404) are gated on `capabilities.agents.roster.supported`
|
|
23
|
+
* and land at Active → Accepted (reference-host roster store deferred per RFC 0086
|
|
24
|
+
* §Conformance). This scenario asserts the wire contract, not host behavior.
|
|
25
|
+
*
|
|
26
|
+
* Spec references:
|
|
27
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-roster.md
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0086-standing-agent-roster-and-workflow-portfolio.md
|
|
29
|
+
* - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (roster-attribution-no-content)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
36
|
+
import addFormats from 'ajv-formats';
|
|
37
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
38
|
+
|
|
39
|
+
/** Server-free assertion-message helper. */
|
|
40
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
41
|
+
|
|
42
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
43
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('agent-roster-shape: capability advertisement (RFC 0086, server-free)', () => {
|
|
47
|
+
it('the capabilities schema declares agents.roster with its sub-flags', () => {
|
|
48
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
49
|
+
const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown> }> }>).agents;
|
|
50
|
+
const roster = agents?.properties?.roster;
|
|
51
|
+
expect(roster, why('capabilities.md §agents', 'agents.roster MUST be declared')).toBeDefined();
|
|
52
|
+
for (const flag of ['supported', 'installScope', 'portfolioTriggerSources']) {
|
|
53
|
+
expect(roster?.properties?.[flag], why('agent-roster.md §F', `agents.roster.${flag} MUST be declared`)).toBeDefined();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('agent-roster-shape: roster entry (RFC 0086 §A, server-free)', () => {
|
|
59
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
60
|
+
addFormats(ajv);
|
|
61
|
+
const entry = ajv.compile(loadSchema('agent-roster-entry.schema.json'));
|
|
62
|
+
|
|
63
|
+
it('AgentRosterEntry validates a conforming entry', () => {
|
|
64
|
+
const good = {
|
|
65
|
+
rosterId: 'host:sally-marketing',
|
|
66
|
+
persona: 'Sally',
|
|
67
|
+
agentRef: { agentId: 'core.openwop.agents.brief-writer', channel: 'stable' },
|
|
68
|
+
workflows: ['marketing-email-campaign'],
|
|
69
|
+
owner: { tenantId: 'acme', workspaceId: 'growth' },
|
|
70
|
+
enabled: true,
|
|
71
|
+
};
|
|
72
|
+
expect(entry(good), why('RFC 0086 §A', 'a conforming roster entry MUST validate')).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects a non-host: rosterId and an agentRef carrying both version and channel', () => {
|
|
76
|
+
const base = {
|
|
77
|
+
rosterId: 'host:sally-marketing',
|
|
78
|
+
persona: 'Sally',
|
|
79
|
+
agentRef: { agentId: 'core.openwop.agents.brief-writer' },
|
|
80
|
+
owner: { tenantId: 'acme' },
|
|
81
|
+
};
|
|
82
|
+
expect(entry({ ...base, rosterId: 'core.openwop.agents.sally' }), why('RFC 0086 §A', 'a non-`host:` rosterId MUST be rejected')).toBe(false);
|
|
83
|
+
expect(
|
|
84
|
+
entry({ ...base, agentRef: { agentId: 'core.x.y.z', version: '1.0.0', channel: 'stable' } }),
|
|
85
|
+
why('RFC 0082 §A', 'an agentRef with BOTH version and channel MUST be rejected'),
|
|
86
|
+
).toBe(false);
|
|
87
|
+
expect(entry({ persona: 'x', agentRef: { agentId: 'core.x.y.z' }, owner: { tenantId: 'acme' } }), why('RFC 0086 §A', 'a roster entry without rosterId MUST be rejected')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('agent-roster-shape: roster.run.initiated event (RFC 0086 §C, server-free)', () => {
|
|
92
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
93
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
94
|
+
addFormats(ajv);
|
|
95
|
+
ajv.addSchema(payloads, 'payloads');
|
|
96
|
+
const initiated = ajv.getSchema('payloads#/$defs/rosterRunInitiated');
|
|
97
|
+
|
|
98
|
+
it('roster.run.initiated validates a content-free attribution record and requires its ids', () => {
|
|
99
|
+
expect(initiated, 'the rosterRunInitiated $def MUST exist').toBeTruthy();
|
|
100
|
+
expect(
|
|
101
|
+
initiated!({ rosterId: 'host:sally-marketing', persona: 'Sally', agentId: 'core.openwop.agents.brief-writer', workflowId: 'marketing-email-campaign', triggerSource: 'schedule' }),
|
|
102
|
+
why('RFC 0086 §C', 'a conforming roster.run.initiated payload MUST validate'),
|
|
103
|
+
).toBe(true);
|
|
104
|
+
expect(initiated!({ rosterId: 'host:s', persona: 'S' }), why('RFC 0086 §C', 'roster.run.initiated without agentId/workflowId/triggerSource MUST be rejected')).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('roster.run.initiated is content-free — a work-item body and a prompt are rejected (roster-attribution-no-content)', () => {
|
|
108
|
+
const base = { rosterId: 'host:s', persona: 'S', agentId: 'a.b.c.d', workflowId: 'wf', triggerSource: 'queue' };
|
|
109
|
+
expect(
|
|
110
|
+
initiated!({ ...base, body: 'the card description' }),
|
|
111
|
+
why('SECURITY invariant roster-attribution-no-content', 'roster.run.initiated MUST NOT carry the work-item body'),
|
|
112
|
+
).toBe(false);
|
|
113
|
+
expect(
|
|
114
|
+
initiated!({ ...base, prompt: 'system: …' }),
|
|
115
|
+
why('SECURITY invariant roster-attribution-no-content', 'roster.run.initiated MUST NOT carry prompt content'),
|
|
116
|
+
).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('agent-roster-shape: inventory projection + enum (RFC 0086 §B, server-free)', () => {
|
|
121
|
+
it('AgentInventoryEntry carries the additive optional roster portfolio projection', () => {
|
|
122
|
+
const inv = loadSchema('agent-inventory-response.schema.json');
|
|
123
|
+
const entry = (inv.$defs as Record<string, { properties?: Record<string, unknown> }>).AgentInventoryEntry?.properties ?? {};
|
|
124
|
+
expect(entry.roster, why('RFC 0086 §B', 'AgentInventoryEntry.roster (the portfolio projection) MUST be declared')).toBeDefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('roster.run.initiated appears in the RunEventType enum', () => {
|
|
128
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
129
|
+
const enumVals = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
|
|
130
|
+
expect(enumVals).toContain('roster.run.initiated');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('the GET /v1/agents/roster response schema validates + rejects extras (RFC 0086 §B)', () => {
|
|
134
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
135
|
+
addFormats(ajv);
|
|
136
|
+
ajv.addSchema(loadSchema('agent-roster-entry.schema.json'), 'https://openwop.dev/spec/v1/agent-roster-entry.schema.json');
|
|
137
|
+
const resp = ajv.compile(loadSchema('agent-roster-response.schema.json'));
|
|
138
|
+
const good = {
|
|
139
|
+
roster: [{ rosterId: 'host:sally', persona: 'Sally', agentRef: { agentId: 'core.x.y.z' }, owner: { tenantId: 'acme' } }],
|
|
140
|
+
total: 1,
|
|
141
|
+
};
|
|
142
|
+
expect(resp(good), why('RFC 0086 §B', 'a conforming GET /v1/agents/roster response MUST validate')).toBe(true);
|
|
143
|
+
expect(resp({ ...good, unexpected: true }), why('RFC 0086 §B', 'an extra top-level property MUST be rejected')).toBe(false);
|
|
144
|
+
expect(resp({ roster: [] }), why('RFC 0086 §B', 'the response MUST require `total`')).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -23,15 +23,10 @@ import Ajv2020 from 'ajv/dist/2020.js';
|
|
|
23
23
|
import { readFileSync } from 'node:fs';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
import { driver } from '../lib/driver.js';
|
|
26
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
26
27
|
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
capabilities?: {
|
|
30
|
-
aiProviders?: { supported?: unknown };
|
|
31
|
-
supportedEnvelopes?: unknown;
|
|
32
|
-
};
|
|
33
|
-
supportedEnvelopes?: unknown;
|
|
34
|
-
}
|
|
29
|
+
type DiscoveryDoc = Record<string, unknown>;
|
|
35
30
|
|
|
36
31
|
const UNIVERSAL_KINDS = [
|
|
37
32
|
'clarification.request',
|
|
@@ -47,19 +42,19 @@ async function readDiscovery(): Promise<DiscoveryDoc | null> {
|
|
|
47
42
|
}
|
|
48
43
|
|
|
49
44
|
function aiProvidersSupported(d: DiscoveryDoc | null): boolean {
|
|
50
|
-
|
|
45
|
+
// Root-first per RFC 0073; `capabilities.aiProviders` is the deprecated wrapper shape.
|
|
46
|
+
const ap = capabilityFamily<{ supported?: unknown }>(d ?? undefined, 'aiProviders');
|
|
47
|
+
if (!ap?.supported) return false;
|
|
51
48
|
// aiProviders.supported can be `true` or an array per the capabilities schema.
|
|
52
|
-
const v =
|
|
49
|
+
const v = ap.supported;
|
|
53
50
|
return v === true || (Array.isArray(v) && v.length > 0);
|
|
54
51
|
}
|
|
55
52
|
|
|
56
53
|
function supportedEnvelopes(d: DiscoveryDoc | null): string[] {
|
|
57
|
-
// supportedEnvelopes is
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
const raw = Array.isArray(top) ? top : Array.isArray(nested) ? nested : [];
|
|
62
|
-
return raw.filter((s): s is string => typeof s === 'string');
|
|
54
|
+
// supportedEnvelopes is a document-root field per capabilities.md §"Document-root
|
|
55
|
+
// layout" (RFC 0073); a `capabilities.*` wrapper is the deprecated fallback.
|
|
56
|
+
const raw = capabilityFamily<unknown>(d ?? undefined, 'supportedEnvelopes');
|
|
57
|
+
return Array.isArray(raw) ? raw.filter((s): s is string => typeof s === 'string') : [];
|
|
63
58
|
}
|
|
64
59
|
|
|
65
60
|
// HTTP-driven blocks soft-skip when no base URL is configured (gate's
|
|
@@ -76,9 +71,10 @@ describe.skipIf(HTTP_SKIP)('ai-envelope-shape: advertisement contract (RFC 0021
|
|
|
76
71
|
for (const k of env) {
|
|
77
72
|
expect(typeof k, driver.describe('capabilities.md §supportedEnvelopes', 'each entry MUST be a string')).toBe('string');
|
|
78
73
|
}
|
|
79
|
-
// Re-affirm shape: array if present.
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
// Re-affirm shape: array if present (root-first per RFC 0073).
|
|
75
|
+
const advertised = capabilityFamily<unknown>(d, 'supportedEnvelopes');
|
|
76
|
+
if (advertised !== undefined) {
|
|
77
|
+
expect(Array.isArray(advertised), 'supportedEnvelopes MUST be an array').toBe(true);
|
|
82
78
|
}
|
|
83
79
|
});
|
|
84
80
|
});
|
|
@@ -33,7 +33,7 @@ interface DiscoveryDoc {
|
|
|
33
33
|
async function readLimits(): Promise<Record<string, number> | null> {
|
|
34
34
|
const res = await driver.get('/.well-known/openwop');
|
|
35
35
|
const body = res.json as DiscoveryDoc | undefined;
|
|
36
|
-
const limits = body?.limits ?? body
|
|
36
|
+
const limits = body?.limits ?? capabilityFamily(body, 'limits') ?? null;
|
|
37
37
|
return limits && typeof limits === 'object' ? (limits as Record<string, number>) : null;
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -166,6 +166,7 @@ describe('aiEnvelope.capBreached: behavioral cap enforcement (FINAL v1.1)', () =
|
|
|
166
166
|
// node.failed per capabilities.md §"Engine-enforced limits". Tests
|
|
167
167
|
// soft-skip on HTTP 404 when the seam isn't exposed.
|
|
168
168
|
import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
169
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
169
170
|
|
|
170
171
|
describe('aiEnvelope.capBreached: engine projection via event-log seam (capabilities.md §"cap.breached")', () => {
|
|
171
172
|
it('breached outcome projects to cap.breached { kind: "envelopes" } event with causationId chain', async () => {
|
|
@@ -54,7 +54,7 @@ describe('aiEnvelope.schemaDrift: advertisement shape (FINAL v1.1)', () => {
|
|
|
54
54
|
if (!(await isEnvelopeContractsAdvertised())) return; // not opted in — skip
|
|
55
55
|
const res = await driver.get('/.well-known/openwop');
|
|
56
56
|
const body = res.json as { schemaVersions?: Record<string, number>; capabilities?: { schemaVersions?: Record<string, number> } } | undefined;
|
|
57
|
-
const versions = body?.schemaVersions ?? body
|
|
57
|
+
const versions = body?.schemaVersions ?? capabilityFamily(body, 'schemaVersions') ?? {};
|
|
58
58
|
expect(
|
|
59
59
|
Object.keys(versions).length > 0,
|
|
60
60
|
driver.describe(
|
|
@@ -189,6 +189,7 @@ describe('aiEnvelope.schemaDrift: behavioral strictness gate (FINAL v1.1)', () =
|
|
|
189
189
|
// E.2 OTel scrape seam.
|
|
190
190
|
import { queryTestSpans, isOtelSeamAvailable } from '../lib/otel-scrape.js';
|
|
191
191
|
import { resetTestSeam } from '../lib/event-log-query.js';
|
|
192
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
192
193
|
|
|
193
194
|
describe('aiEnvelope.schemaDrift: OTel drift attribute projection (E.2)', () => {
|
|
194
195
|
it('below-floor + strictness:warn → OTel span MUST carry envelope_schema_version_drift attribute', async () => {
|
|
@@ -166,6 +166,7 @@ describe('aiEnvelope.universalKinds: behavioral accept via /v1/host/sample/envel
|
|
|
166
166
|
|
|
167
167
|
// E.1 engine-projection via the test-only event-log seam.
|
|
168
168
|
import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
169
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
169
170
|
|
|
170
171
|
describe('aiEnvelope.universalKinds: engine projection via event-log seam', () => {
|
|
171
172
|
it('clarification.request MUST be lifted to interrupt.requested { kind: "clarification" } per interrupt.md', async () => {
|
|
@@ -254,7 +255,7 @@ describe('aiEnvelope.universalKinds: schema.response counter-policy advertisemen
|
|
|
254
255
|
// advertising a policy field use a documented value.
|
|
255
256
|
const res = await driver.get('/.well-known/openwop');
|
|
256
257
|
const body = res.json as { capabilities?: { aiEnvelope?: { schemaResponseCounterPolicy?: string } } } | undefined;
|
|
257
|
-
const policy = body
|
|
258
|
+
const policy = capabilityFamily<{ schemaResponseCounterPolicy?: string }>(body, 'aiEnvelope')?.schemaResponseCounterPolicy;
|
|
258
259
|
if (policy === undefined) return; // no policy advertised — host MAY omit
|
|
259
260
|
expect(
|
|
260
261
|
['counted', 'exempt'].includes(policy),
|
|
@@ -22,14 +22,12 @@
|
|
|
22
22
|
|
|
23
23
|
import { describe, it, expect } from 'vitest';
|
|
24
24
|
import { driver } from '../lib/driver.js';
|
|
25
|
-
|
|
26
|
-
interface DiscoveryDoc {
|
|
27
|
-
capabilities?: { authorization?: { supported?: boolean } };
|
|
28
|
-
}
|
|
25
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
29
26
|
|
|
30
27
|
async function authorizationSupported(): Promise<boolean> {
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
// Root-first per RFC 0073 (`capabilities.authorization` is the deprecated wrapper shape).
|
|
29
|
+
const authz = await readCapabilityFamily<{ supported?: unknown }>('authorization');
|
|
30
|
+
return authz?.supported === true;
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
describe('approval-gate-flow: role-gated, audited approval (RFC 0051 §A)', () => {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded artifact-schema compilation (RFC 0071, `Active`).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free assertion for the SECURITY invariant
|
|
5
|
+
* `artifact-schema-compile-bounded`. Artifact-type packs ship third-party
|
|
6
|
+
* JSON Schemas that the engine compiles (Ajv) at install + validation time;
|
|
7
|
+
* an unbounded compile is a denial-of-service vector (schema bombs:
|
|
8
|
+
* pathological `$ref` recursion, keyword-count explosion, oversized payloads,
|
|
9
|
+
* catastrophic-backtracking `pattern`s). This scenario asserts two things
|
|
10
|
+
* that must hold for every release regardless of which host runs it:
|
|
11
|
+
*
|
|
12
|
+
* PART 1 — contract present. `artifact-type-packs.md` carries the normative
|
|
13
|
+
* bounded-compilation MUST (serialized-size, `$ref`-depth, keyword-count
|
|
14
|
+
* bounds + wall-clock timeout), and `host-capabilities.md` §host.artifactTypes
|
|
15
|
+
* references it. Guards against the requirement being silently dropped.
|
|
16
|
+
*
|
|
17
|
+
* PART 2 — defense is well-defined + implementable. A reference bounding
|
|
18
|
+
* predicate built from representative finite limits rejects three schema
|
|
19
|
+
* bombs and admits a benign artifact schema. The specific numeric limits are
|
|
20
|
+
* host-configurable per the spec (advertised, not protocol-mandated); the
|
|
21
|
+
* point is that *some* finite bound exists and catches the bombs while
|
|
22
|
+
* passing legitimate schemas.
|
|
23
|
+
*
|
|
24
|
+
* The behavioral end-to-end form (a host rejects an over-bounds pack at
|
|
25
|
+
* registry `PUT` with `pack_validation_failed`) is capability-gated on
|
|
26
|
+
* `host.artifactTypes.supported` and is `host-pending` until a reference host
|
|
27
|
+
* lands; this server-free scenario is the always-on floor.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v1/artifact-type-packs.md §"Bounded schema compilation (normative)"
|
|
30
|
+
* @see SECURITY/threat-model-node-packs.md §"Distributed artifact schemas"
|
|
31
|
+
* @see RFCS/0071-artifact-type-and-chat-card-packs.md
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { V1_DIR } from '../lib/paths.js';
|
|
38
|
+
|
|
39
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
40
|
+
|
|
41
|
+
describe('artifact-schema-compile-bounded: contract present in the corpus (RFC 0071, server-free)', () => {
|
|
42
|
+
const artifactDoc = V1_DIR ? readFileSync(join(V1_DIR, 'artifact-type-packs.md'), 'utf8') : '';
|
|
43
|
+
const hostCaps = V1_DIR ? readFileSync(join(V1_DIR, 'host-capabilities.md'), 'utf8') : '';
|
|
44
|
+
|
|
45
|
+
it.skipIf(V1_DIR === null)('artifact-type-packs.md declares the bounded-compilation MUST', () => {
|
|
46
|
+
expect(
|
|
47
|
+
/Bounded schema compilation/i.test(artifactDoc),
|
|
48
|
+
why('artifact-type-packs.md', 'a "Bounded schema compilation" section MUST exist'),
|
|
49
|
+
).toBe(true);
|
|
50
|
+
expect(
|
|
51
|
+
/MUST bound/i.test(artifactDoc) && /MUST reject/i.test(artifactDoc),
|
|
52
|
+
why('artifact-type-packs.md §"Bounded schema compilation"', 'host MUST bound + MUST reject over-limit schemas'),
|
|
53
|
+
).toBe(true);
|
|
54
|
+
// The three structural axes + the timeout MUST all be named.
|
|
55
|
+
for (const axis of [/byte size/i, /\$ref/i, /keyword/i, /timeout/i]) {
|
|
56
|
+
expect(axis.test(artifactDoc), why('artifact-type-packs.md', `bound axis ${axis} MUST be named`)).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.skipIf(V1_DIR === null)('host-capabilities.md §host.artifactTypes references the bound', () => {
|
|
61
|
+
expect(
|
|
62
|
+
/artifact-schema-compile-bounded/.test(hostCaps),
|
|
63
|
+
why('host-capabilities.md §host.artifactTypes', 'MUST reference the bounded-compilation invariant'),
|
|
64
|
+
).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('artifact-schema-compile-bounded: a finite bound catches schema bombs (RFC 0071, server-free)', () => {
|
|
69
|
+
// Representative, host-configurable limits (the spec leaves the exact values
|
|
70
|
+
// to host advertisement; these stand in for "some finite bound").
|
|
71
|
+
const LIMITS = { maxBytes: 64 * 1024, maxRefDepth: 16, maxKeywords: 2000 };
|
|
72
|
+
|
|
73
|
+
function refDepth(node: unknown, seen = 0): number {
|
|
74
|
+
if (node === null || typeof node !== 'object') return seen;
|
|
75
|
+
const obj = node as Record<string, unknown>;
|
|
76
|
+
const here = '$ref' in obj ? seen + 1 : seen;
|
|
77
|
+
let max = here;
|
|
78
|
+
for (const v of Object.values(obj)) max = Math.max(max, refDepth(v, here));
|
|
79
|
+
return max;
|
|
80
|
+
}
|
|
81
|
+
function keywordCount(node: unknown): number {
|
|
82
|
+
if (node === null || typeof node !== 'object') return 0;
|
|
83
|
+
const obj = node as Record<string, unknown>;
|
|
84
|
+
let n = Object.keys(obj).length;
|
|
85
|
+
for (const v of Object.values(obj)) n += keywordCount(v);
|
|
86
|
+
return n;
|
|
87
|
+
}
|
|
88
|
+
/** Reference bound predicate — the shape a conformant host applies at PUT/install. */
|
|
89
|
+
function exceedsBounds(schema: unknown): boolean {
|
|
90
|
+
const bytes = Buffer.byteLength(JSON.stringify(schema), 'utf8');
|
|
91
|
+
if (bytes > LIMITS.maxBytes) return true;
|
|
92
|
+
if (refDepth(schema) > LIMITS.maxRefDepth) return true;
|
|
93
|
+
if (keywordCount(schema) > LIMITS.maxKeywords) return true;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
it('admits a benign artifact schema', () => {
|
|
98
|
+
const benign = {
|
|
99
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
100
|
+
$id: 'https://h.example/schemas/artifacts/vendor.acme.cad.model.schema.json',
|
|
101
|
+
type: 'object',
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
required: ['name'],
|
|
104
|
+
properties: { name: { type: 'string' }, dims: { type: 'array', items: { type: 'number' } } },
|
|
105
|
+
};
|
|
106
|
+
expect(exceedsBounds(benign), why('artifact-type-packs.md', 'a legitimate artifact schema MUST NOT be rejected')).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects a $ref-depth bomb', () => {
|
|
110
|
+
// Nest $ref-bearing objects deeper than maxRefDepth so resolution depth accumulates.
|
|
111
|
+
let node: Record<string, unknown> = { type: 'string' };
|
|
112
|
+
for (let i = 0; i < LIMITS.maxRefDepth + 4; i++) node = { $ref: '#/x', properties: { nested: node } };
|
|
113
|
+
expect(exceedsBounds({ type: 'object', properties: { deep: node } }), why('threat-model-node-packs.md', 'a $ref-depth bomb MUST be rejected')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('rejects a keyword-count bomb', () => {
|
|
117
|
+
const props: Record<string, unknown> = {};
|
|
118
|
+
for (let i = 0; i < LIMITS.maxKeywords + 100; i++) props[`p${i}`] = { type: 'string' };
|
|
119
|
+
expect(exceedsBounds({ type: 'object', properties: props }), why('threat-model-node-packs.md', 'a keyword-count bomb MUST be rejected')).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects an oversized schema', () => {
|
|
123
|
+
const huge = { type: 'object', description: 'x'.repeat(LIMITS.maxBytes + 1) };
|
|
124
|
+
expect(exceedsBounds(huge), why('threat-model-node-packs.md', 'an over-size schema MUST be rejected')).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifact-type-pack-install — RFC 0071 Phase 1 §"Binding the existing artifact
|
|
3
|
+
* surfaces". A host that advertises `host.artifactTypes` installs an
|
|
4
|
+
* artifact-type pack, then produces an artifact of a registered type:
|
|
5
|
+
*
|
|
6
|
+
* - a payload that conforms to the pack schema is stored and surfaces an
|
|
7
|
+
* `artifact.created` with `registered: true` (validated against the pack);
|
|
8
|
+
* - a payload that violates the schema is rejected (not stored, no
|
|
9
|
+
* `registered: true` artifact.created).
|
|
10
|
+
*
|
|
11
|
+
* Gated on `capabilities.host.artifactTypes.supported` + the host-sample
|
|
12
|
+
* install/produce seam; soft-skips when either is absent (`host-pending`
|
|
13
|
+
* until a reference host wires RFC 0071 — see the migration request at
|
|
14
|
+
* docs/openwop-adoption/0071-artifact-type-packs-migration-request.md).
|
|
15
|
+
*
|
|
16
|
+
* @see spec/v1/artifact-type-packs.md §"Binding the existing artifact surfaces"
|
|
17
|
+
* @see RFCS/0071-artifact-type-and-chat-card-packs.md
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { driver } from '../lib/driver.js';
|
|
22
|
+
import {
|
|
23
|
+
readArtifactTypesCap,
|
|
24
|
+
artifactTypesSupported,
|
|
25
|
+
installArtifactTypePack,
|
|
26
|
+
produceArtifact,
|
|
27
|
+
sampleArtifactTypePack,
|
|
28
|
+
} from '../lib/artifactTypes.js';
|
|
29
|
+
|
|
30
|
+
describe('artifact-type-pack-install: registered artifacts are schema-validated (RFC 0071)', () => {
|
|
31
|
+
it('a conforming payload yields artifact.created { registered: true }', async () => {
|
|
32
|
+
if (!artifactTypesSupported(await readArtifactTypesCap())) return; // unadvertised — soft-skip
|
|
33
|
+
const { artifactTypeId, manifest, schema } = sampleArtifactTypePack();
|
|
34
|
+
|
|
35
|
+
const installed = await installArtifactTypePack(manifest, { [artifactTypeId]: schema });
|
|
36
|
+
if (installed === null) return; // seam absent — soft-skip
|
|
37
|
+
expect(
|
|
38
|
+
installed.status >= 200 && installed.status < 300,
|
|
39
|
+
driver.describe('artifact-type-packs.md §"Pack kind"', 'a valid artifact-type pack MUST install cleanly'),
|
|
40
|
+
).toBe(true);
|
|
41
|
+
|
|
42
|
+
const produced = await produceArtifact(artifactTypeId, { title: 'Hello', body: 'World' });
|
|
43
|
+
if (produced === null) return; // seam absent — soft-skip
|
|
44
|
+
expect(
|
|
45
|
+
produced.json['registered'],
|
|
46
|
+
driver.describe('artifact-type-packs.md §"Binding the existing artifact surfaces"', 'a payload matching a registered artifactTypeId MUST be marked registered'),
|
|
47
|
+
).toBe(true);
|
|
48
|
+
expect(
|
|
49
|
+
produced.json['validated'],
|
|
50
|
+
driver.describe('artifact-type-packs.md', 'the host MUST validate the payload against the pack schema before emitting artifact.created'),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
const evt = produced.json['artifactCreated'] as { registered?: unknown } | undefined;
|
|
53
|
+
if (evt && 'registered' in evt) {
|
|
54
|
+
expect(
|
|
55
|
+
evt.registered,
|
|
56
|
+
driver.describe('run-event-payloads.schema.json §artifactCreated', 'artifact.created.registered MUST be true for a validated registered artifact'),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('a schema-violating payload is rejected (not stored as a validated registered artifact)', async () => {
|
|
62
|
+
if (!artifactTypesSupported(await readArtifactTypesCap())) return;
|
|
63
|
+
const { artifactTypeId, manifest, schema } = sampleArtifactTypePack();
|
|
64
|
+
if ((await installArtifactTypePack(manifest, { [artifactTypeId]: schema })) === null) return;
|
|
65
|
+
|
|
66
|
+
// `body` missing + a foreign key → fails additionalProperties:false + required.
|
|
67
|
+
const produced = await produceArtifact(artifactTypeId, { title: 'Hello', extra: true });
|
|
68
|
+
if (produced === null) return;
|
|
69
|
+
const rejected =
|
|
70
|
+
produced.status >= 400 ||
|
|
71
|
+
produced.json['validated'] === false ||
|
|
72
|
+
produced.json['stored'] === false;
|
|
73
|
+
expect(
|
|
74
|
+
rejected,
|
|
75
|
+
driver.describe('artifact-type-packs.md §"Binding the existing artifact surfaces"', 'a payload that fails the pack schema MUST NOT be emitted as a validated registered artifact'),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact-type pack manifest validation — `artifact-type-packs.md` §"Manifest format"
|
|
3
|
+
* + `schemas/artifact-type-pack-manifest.schema.json` (RFC 0071 Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* Server-free schema-validation scenario. Exercises the new
|
|
6
|
+
* `artifact-type-pack-manifest.schema.json` with a positive sample and the
|
|
7
|
+
* negative samples derived from the RFC's "Examples" section that are
|
|
8
|
+
* expressible at the JSON-Schema layer:
|
|
9
|
+
*
|
|
10
|
+
* 1. Positive: a valid `kind: "artifact-type"` manifest with a single
|
|
11
|
+
* `artifactTypes[]` entry validates cleanly.
|
|
12
|
+
* 2. Negative — kind/contents mismatch: a manifest carrying BOTH
|
|
13
|
+
* `artifactTypes[]` AND `nodes[]` is rejected. Surface-level outcome at
|
|
14
|
+
* the registry HTTP API is `pack_kind_invalid` per the spec;
|
|
15
|
+
* schema-level outcome is an `additionalProperties` violation on
|
|
16
|
+
* `nodes` (this schema does not declare that field).
|
|
17
|
+
* 3. Negative — empty `artifactTypes[]`: rejected with a `minItems`
|
|
18
|
+
* violation (a pack MUST declare at least one artifact type).
|
|
19
|
+
* 4. Negative — invalid `artifactTypeId`: a value that does not match the
|
|
20
|
+
* reverse-DNS pattern (e.g. an uppercase scope) is rejected with a
|
|
21
|
+
* `pattern` violation.
|
|
22
|
+
* 5. Negative — unknown `rendering.display`: a value outside the closed
|
|
23
|
+
* enum (`"3d-viewport"`) is rejected with an `enum` violation
|
|
24
|
+
* (the RenderingHint vocabulary is reused from RFC 0055, `card` excluded).
|
|
25
|
+
* 6. Negative — non-conforming `exportFormats` identifier: an uppercase /
|
|
26
|
+
* unprefixed value is rejected with a `pattern` violation (reserved-core
|
|
27
|
+
* + `vendor.*`/`x-` extension idiom).
|
|
28
|
+
*
|
|
29
|
+
* NOTE: the RFC's "core scope published from a non-core account" negative is a
|
|
30
|
+
* registry-PUT enforcement rule (account ↔ scope binding), NOT a schema
|
|
31
|
+
* constraint — `core.*` is a valid `artifactTypeId` pattern. It is therefore
|
|
32
|
+
* not asserted here; it belongs to the capability-gated publish scenario.
|
|
33
|
+
*
|
|
34
|
+
* Capability-gated end-to-end scenarios (install + validate; store-without-
|
|
35
|
+
* render negotiation) are deferred and gate on `host.artifactTypes.supported`
|
|
36
|
+
* per the RFC; behavior grade is `host-pending` until a reference host lands.
|
|
37
|
+
*
|
|
38
|
+
* @see spec/v1/artifact-type-packs.md
|
|
39
|
+
* @see schemas/artifact-type-pack-manifest.schema.json
|
|
40
|
+
* @see RFCS/0071-artifact-type-and-chat-card-packs.md
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { describe, it, expect } from 'vitest';
|
|
44
|
+
import { readFileSync } from 'node:fs';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
47
|
+
import addFormats from 'ajv-formats';
|
|
48
|
+
import type { ErrorObject } from 'ajv';
|
|
49
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
50
|
+
|
|
51
|
+
const SCHEMA_PATH = join(SCHEMAS_DIR, 'artifact-type-pack-manifest.schema.json');
|
|
52
|
+
|
|
53
|
+
function validManifest() {
|
|
54
|
+
return {
|
|
55
|
+
kind: 'artifact-type',
|
|
56
|
+
name: 'vendor.acme.cad',
|
|
57
|
+
version: '1.0.0',
|
|
58
|
+
engines: { openwop: '>=1.1 <2.0.0' },
|
|
59
|
+
artifactTypes: [
|
|
60
|
+
{
|
|
61
|
+
artifactTypeId: 'vendor.acme.cad.model',
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
schemaRef: 'schemas/cad-model.schema.json',
|
|
64
|
+
rendering: { display: 'file', mimeType: 'model/step' },
|
|
65
|
+
exportFormats: ['step', 'stl', 'pdf'],
|
|
66
|
+
syncOn: 'completion',
|
|
67
|
+
supportsCheckpoint: true,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('category: artifact-type-pack manifest validation', () => {
|
|
74
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
75
|
+
addFormats(ajv);
|
|
76
|
+
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
|
|
77
|
+
const validate = ajv.compile(schema);
|
|
78
|
+
|
|
79
|
+
const failsWith = (manifest: unknown, keyword: string): ErrorObject[] => {
|
|
80
|
+
const ok = validate(manifest);
|
|
81
|
+
expect(ok).toBe(false);
|
|
82
|
+
const errs = (validate.errors ?? []).filter((e) => e.keyword === keyword);
|
|
83
|
+
return errs;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
it('positive: a valid artifact-type pack manifest validates cleanly', () => {
|
|
87
|
+
const ok = validate(validManifest());
|
|
88
|
+
expect(
|
|
89
|
+
ok,
|
|
90
|
+
`artifact-type-packs.md §"Manifest format": a well-formed kind:"artifact-type" manifest MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
|
|
91
|
+
).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('negative: a manifest mixing artifactTypes[] and nodes[] is rejected (pack_kind_invalid at the registry)', () => {
|
|
95
|
+
const manifest = { ...validManifest(), nodes: [{ typeId: 'vendor.acme.x', version: '1.0.0', category: 'data', role: 'pure' }] };
|
|
96
|
+
const errs = failsWith(manifest, 'additionalProperties');
|
|
97
|
+
expect(
|
|
98
|
+
errs.some((e) => (e.params as { additionalProperty?: string }).additionalProperty === 'nodes'),
|
|
99
|
+
'artifact-type-packs.md §"Pack kind": one kind per pack — a foreign `nodes[]` field MUST be rejected (additionalProperties:false)',
|
|
100
|
+
).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('negative: an empty artifactTypes[] is rejected (a pack MUST declare ≥1 type)', () => {
|
|
104
|
+
const manifest = { ...validManifest(), artifactTypes: [] };
|
|
105
|
+
const errs = failsWith(manifest, 'minItems');
|
|
106
|
+
expect(errs.length, 'artifact-type-pack-manifest.schema.json: artifactTypes minItems:1').toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('negative: an artifactTypeId that is not reverse-DNS scoped is rejected', () => {
|
|
110
|
+
const manifest = validManifest();
|
|
111
|
+
manifest.artifactTypes[0]!.artifactTypeId = 'Vendor.Acme.Model'; // uppercase scope
|
|
112
|
+
const errs = failsWith(manifest, 'pattern');
|
|
113
|
+
expect(errs.length, 'artifact-type-packs.md: artifactTypeId MUST match the reverse-DNS pattern').toBeGreaterThan(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('negative: an unknown rendering.display value is rejected (closed RFC 0055 enum, card excluded)', () => {
|
|
117
|
+
const manifest = validManifest();
|
|
118
|
+
(manifest.artifactTypes[0]!.rendering as { display: string }).display = '3d-viewport';
|
|
119
|
+
const errs = failsWith(manifest, 'enum');
|
|
120
|
+
expect(errs.length, 'artifact-type-packs.md: rendering.display reuses the closed ai-envelope §"Rendering hints" enum').toBeGreaterThan(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('negative: a non-conforming exportFormats identifier is rejected (reserved-core + vendor.*/x- only)', () => {
|
|
124
|
+
const manifest = validManifest();
|
|
125
|
+
manifest.artifactTypes[0]!.exportFormats = ['PPTX']; // uppercase, unprefixed
|
|
126
|
+
const errs = failsWith(manifest, 'pattern');
|
|
127
|
+
expect(errs.length, 'artifact-type-packs.md: exportFormats identifiers are lowercase core ids OR vendor.*/x- extensions').toBeGreaterThan(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('positive: the validation field accepts "open"/"closed" and rejects other values (RFC 0075)', () => {
|
|
131
|
+
for (const v of ['open', 'closed']) {
|
|
132
|
+
const m = validManifest();
|
|
133
|
+
(m.artifactTypes[0] as Record<string, unknown>).validation = v;
|
|
134
|
+
expect(validate(m), `artifact-type-packs.md §validation: "${v}" MUST validate (RFC 0075)`).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
const bad = validManifest();
|
|
137
|
+
(bad.artifactTypes[0] as Record<string, unknown>).validation = 'lenient';
|
|
138
|
+
expect(failsWith(bad, 'enum').length, 'validation MUST be open|closed').toBeGreaterThan(0);
|
|
139
|
+
});
|
|
140
|
+
});
|