@openwop/openwop-conformance 1.10.0 → 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 +34 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +70 -0
- package/api/openapi.yaml +268 -1
- package/coverage.md +30 -2
- package/fixtures/oauth-providers/synthetic.json +38 -0
- package/fixtures.md +10 -0
- package/package.json +1 -1
- package/schemas/README.md +12 -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 +25 -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/budget-policy.schema.json +18 -0
- package/schemas/capabilities.schema.json +277 -0
- package/schemas/credential-provenance.schema.json +18 -0
- package/schemas/eval-summary.schema.json +92 -0
- package/schemas/node-pack-manifest.schema.json +17 -0
- package/schemas/org-chart-responsibility-view.schema.json +26 -0
- package/schemas/run-event-payloads.schema.json +286 -3
- package/schemas/run-event.schema.json +19 -0
- package/schemas/tool-descriptor.schema.json +63 -0
- package/schemas/trigger-subscription.schema.json +26 -0
- package/src/lib/agentRoster.ts +76 -0
- package/src/lib/liveRuntime.ts +59 -0
- package/src/lib/profiles.ts +157 -0
- package/src/lib/runtimeRequires.ts +38 -0
- package/src/lib/safeFetch.ts +87 -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-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/budget-policy-shape.test.ts +136 -0
- package/src/scenarios/egress-provenance-shape.test.ts +137 -0
- package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -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/spec-corpus-validity.test.ts +19 -3
- package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
- package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
- package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openwop-agent-platform — operational-annex predicate + status derivation (RFC 0085).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free derivation probe. Verifies that:
|
|
5
|
+
* - `isAgentPlatformPartial` / `isAgentPlatformFull` / `agentPlatformStatus`
|
|
6
|
+
* derive `none` / `partial` / `full` correctly from representative discovery
|
|
7
|
+
* payloads (RFC 0085 §B).
|
|
8
|
+
* - the floor's replay-OR-nondeterminism term is honored: a host with no
|
|
9
|
+
* `replay.supported` but `nondeterminismPolicy.declared: true` still meets the
|
|
10
|
+
* floor.
|
|
11
|
+
* - the `full` tier requires the governance terms (RBAC + tenant installScope +
|
|
12
|
+
* memory.attribution + debug-bundle + trigger-bridge + egress-policy); a host
|
|
13
|
+
* missing any reports `partial`, never `full` (the honest-advertisement rule).
|
|
14
|
+
* - `capabilities.nondeterminismPolicy.declared` is declared in the schema.
|
|
15
|
+
*
|
|
16
|
+
* The LIVE aggregate-evidence assertion (does every required constituent scenario
|
|
17
|
+
* actually pass against a host claiming `full`?) is the `Active → Accepted` step
|
|
18
|
+
* per RFC 0085 §C — naturally gated on a reference host reaching partial/full, and
|
|
19
|
+
* deferred here. This scenario asserts the discovery-predicate derivation only.
|
|
20
|
+
*
|
|
21
|
+
* Spec references:
|
|
22
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-platform-profile.md
|
|
23
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0085-agent-platform-meta-profile.md
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
30
|
+
import { isAgentPlatformPartial, isAgentPlatformFull, agentPlatformStatus, agentPlatformSatisfiedTerms } from '../lib/profiles.js';
|
|
31
|
+
|
|
32
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
33
|
+
|
|
34
|
+
const CORE = {
|
|
35
|
+
protocolVersion: '1.0',
|
|
36
|
+
supportedEnvelopes: ['clarification.request'],
|
|
37
|
+
schemaVersions: {},
|
|
38
|
+
limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** A discovery payload meeting the §B floor (partial). */
|
|
42
|
+
function floorPayload(extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
43
|
+
return {
|
|
44
|
+
...CORE,
|
|
45
|
+
agents: { manifestRuntime: { supported: true }, liveRuntime: { supported: true } },
|
|
46
|
+
toolCatalog: { supported: true },
|
|
47
|
+
toolHooks: { supported: true },
|
|
48
|
+
httpClient: { safeFetch: { supported: true } },
|
|
49
|
+
providerUsage: { supported: true },
|
|
50
|
+
prompts: { supported: true },
|
|
51
|
+
memory: { supported: true },
|
|
52
|
+
feedback: { supported: true },
|
|
53
|
+
replay: { supported: true },
|
|
54
|
+
...extra,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('agent-platform-profile: floor (partial) predicate (RFC 0085 §B, server-free)', () => {
|
|
59
|
+
it('a host meeting all floor flags is partial', () => {
|
|
60
|
+
const c = floorPayload();
|
|
61
|
+
expect(isAgentPlatformPartial(c), why('agent-platform-profile.md §B', 'all floor flags ⇒ partial')).toBe(true);
|
|
62
|
+
expect(agentPlatformStatus(c)).toBe('partial');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('missing a single floor flag (feedback) ⇒ none', () => {
|
|
66
|
+
const c = floorPayload({ feedback: { supported: false } });
|
|
67
|
+
expect(isAgentPlatformPartial(c), why('agent-platform-profile.md §B', 'a missing floor flag ⇒ not partial')).toBe(false);
|
|
68
|
+
expect(agentPlatformStatus(c)).toBe('none');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('replay-OR-nondeterminism: no replay but declared nondeterminism still meets the floor', () => {
|
|
72
|
+
const c = floorPayload({ replay: { supported: false }, nondeterminismPolicy: { declared: true } });
|
|
73
|
+
expect(isAgentPlatformPartial(c), why('agent-platform-profile.md §B', 'declared nondeterminism satisfies the replay-OR term')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('neither replay nor declared nondeterminism ⇒ floor unmet', () => {
|
|
77
|
+
const c = floorPayload({ replay: { supported: false } });
|
|
78
|
+
expect(isAgentPlatformPartial(c), why('agent-platform-profile.md §B', 'neither replay nor declared policy ⇒ not partial')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('agent-platform-profile: full predicate + honest-advertisement (RFC 0085 §B/§D, server-free)', () => {
|
|
83
|
+
const fullExtra = {
|
|
84
|
+
authorization: { supported: true },
|
|
85
|
+
agents: { manifestRuntime: { supported: true, installScope: 'tenant' }, liveRuntime: { supported: true } },
|
|
86
|
+
memory: { supported: true, attribution: { supported: true } },
|
|
87
|
+
debugBundle: { supported: true },
|
|
88
|
+
triggerBridge: { supported: true },
|
|
89
|
+
httpClient: { safeFetch: { supported: true }, egressPolicy: { supported: true } },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
it('a host meeting floor + all governance terms is full', () => {
|
|
93
|
+
const c = floorPayload(fullExtra);
|
|
94
|
+
expect(isAgentPlatformFull(c), why('agent-platform-profile.md §B', 'floor + governance ⇒ full')).toBe(true);
|
|
95
|
+
expect(agentPlatformStatus(c)).toBe('full');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('a host advertising governance flags but missing tenant installScope reports partial, not full', () => {
|
|
99
|
+
const c = floorPayload({
|
|
100
|
+
...fullExtra,
|
|
101
|
+
agents: { manifestRuntime: { supported: true, installScope: 'host' }, liveRuntime: { supported: true } },
|
|
102
|
+
});
|
|
103
|
+
expect(isAgentPlatformFull(c), why('agent-platform-profile.md §D', 'missing a governance term ⇒ MUST NOT be full')).toBe(false);
|
|
104
|
+
expect(agentPlatformStatus(c)).toBe('partial');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('eval/deploy/budget are NOT hard full terms (a full host without them is still full)', () => {
|
|
108
|
+
const c = floorPayload(fullExtra); // no agents.evalSuite / agents.deployment / budget
|
|
109
|
+
expect(isAgentPlatformFull(c), why('agent-platform-profile.md §B', 'platform-plus tier is advisory, not a hard full term')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('agent-platform-profile: satisfiedTerms[] non-contiguous adoption (RFC 0085 §D, server-free)', () => {
|
|
114
|
+
it('a host honoring full-tier terms but failing floor terms is status none yet has a non-empty satisfiedTerms[]', () => {
|
|
115
|
+
// The real-host (MyndHyve) shape: RBAC + memory.attribution + tenant installScope (3 full terms)
|
|
116
|
+
// satisfied, while liveRuntime / toolCatalog / providerUsage / memory floor terms are absent.
|
|
117
|
+
const c = {
|
|
118
|
+
...CORE,
|
|
119
|
+
agents: { manifestRuntime: { supported: true, installScope: 'tenant' } }, // no liveRuntime
|
|
120
|
+
authorization: { supported: true },
|
|
121
|
+
memory: { attribution: { supported: true } }, // attribution but NOT memory.supported
|
|
122
|
+
toolHooks: { supported: true },
|
|
123
|
+
httpClient: { safeFetch: { supported: true } },
|
|
124
|
+
prompts: { supported: true },
|
|
125
|
+
feedback: { supported: true },
|
|
126
|
+
replay: { supported: true },
|
|
127
|
+
} as Record<string, unknown>;
|
|
128
|
+
expect(agentPlatformStatus(c), why('agent-platform-profile.md §D', 'floor unmet ⇒ none')).toBe('none');
|
|
129
|
+
const terms = agentPlatformSatisfiedTerms(c);
|
|
130
|
+
expect(terms.includes('full:authorization'), why('§D', 'a satisfied full term is reported even at none')).toBe(true);
|
|
131
|
+
expect(terms.includes('full:memory.attribution')).toBe(true);
|
|
132
|
+
expect(terms.includes('full:tenant-installScope')).toBe(true);
|
|
133
|
+
expect(terms.includes('floor:agents.liveRuntime'), why('§D', 'an unmet floor term is NOT reported')).toBe(false);
|
|
134
|
+
expect(terms.length).toBeGreaterThan(0); // distinguishable from a 0/16 do-nothing host
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('a full host reports all sixteen terms satisfied', () => {
|
|
138
|
+
const c = floorPayload({
|
|
139
|
+
authorization: { supported: true },
|
|
140
|
+
agents: { manifestRuntime: { supported: true, installScope: 'tenant' }, liveRuntime: { supported: true } },
|
|
141
|
+
memory: { supported: true, attribution: { supported: true } },
|
|
142
|
+
debugBundle: { supported: true },
|
|
143
|
+
triggerBridge: { supported: true },
|
|
144
|
+
httpClient: { safeFetch: { supported: true }, egressPolicy: { supported: true } },
|
|
145
|
+
});
|
|
146
|
+
expect(agentPlatformSatisfiedTerms(c).length, why('§D', 'a full host satisfies all 16 terms')).toBe(16);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('agent-platform-profile: capability shape (RFC 0085, server-free)', () => {
|
|
151
|
+
it('capabilities.nondeterminismPolicy.declared is declared', () => {
|
|
152
|
+
const caps = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8')) as { properties?: Record<string, { properties?: Record<string, unknown> }> };
|
|
153
|
+
expect(
|
|
154
|
+
caps.properties?.nondeterminismPolicy?.properties?.declared,
|
|
155
|
+
why('agent-platform-profile.md §B', 'capabilities.nondeterminismPolicy.declared MUST be declared'),
|
|
156
|
+
).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standing-agent roster attribution + ordering (RFC 0086 §B/§C) — behavioral.
|
|
3
|
+
*
|
|
4
|
+
* Gated on `capabilities.agents.roster.supported` (root-first per RFC 0073).
|
|
5
|
+
* Soft-skips when unadvertised (default) / hard-fails under
|
|
6
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` via `behaviorGate`. The companion always-on
|
|
7
|
+
* wire-shape coverage lives in `agent-roster-shape.test.ts`; this scenario
|
|
8
|
+
* asserts host BEHAVIOR:
|
|
9
|
+
*
|
|
10
|
+
* 1. NORMATIVE read — `GET /v1/agents/roster` (RFC 0086 §B) returns the
|
|
11
|
+
* `agent-roster-response` shape (roster[] + `total == roster.length`), and
|
|
12
|
+
* every entry carries a `host:<id>` `rosterId`, a `persona`, an
|
|
13
|
+
* `agentRef.agentId`, and an `owner.tenantId`. Runs black-box against the
|
|
14
|
+
* normative path on any roster host.
|
|
15
|
+
* 2. ATTRIBUTION + ORDERING (seam-gated) — a portfolio fire emits
|
|
16
|
+
* `roster.run.initiated` as the run's FIRST attribution event, BEFORE any
|
|
17
|
+
* `agent.invocation.*` / `agent.*` event (§C), content-free (no work-item
|
|
18
|
+
* `body`/`prompt`/credential — the `roster-attribution-no-content`
|
|
19
|
+
* invariant), with `rosterId`/`persona`/`agentId`/`workflowId`/
|
|
20
|
+
* `triggerSource`. A durable work-item fire additionally carries
|
|
21
|
+
* `triggerSubscriptionId` (RFC 0083) traceable on the run's `causationId`.
|
|
22
|
+
* 3. TENANT SCOPING (§B / RFC 0074) — a `GET /v1/agents/roster/{id}` for an id
|
|
23
|
+
* outside the caller's owner triple 404s (probed only when a cross-tenant id
|
|
24
|
+
* is supplied via `OPENWOP_CROSS_TENANT_ROSTER_ID`; soft-skip otherwise).
|
|
25
|
+
*
|
|
26
|
+
* The fire + event-log seams are OPTIONAL (reference roster store deferred per
|
|
27
|
+
* RFC 0086 §Conformance); each leg soft-skips independently so a host that
|
|
28
|
+
* serves only the normative read still exercises leg 1.
|
|
29
|
+
*
|
|
30
|
+
* Spec references:
|
|
31
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-roster.md
|
|
32
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0086-standing-agent-roster-and-workflow-portfolio.md
|
|
33
|
+
* - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (roster-attribution-no-content)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { describe, it, expect } from 'vitest';
|
|
37
|
+
import { driver } from '../lib/driver.js';
|
|
38
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
39
|
+
import { readRosterCap, listRoster, getRosterEntry, fireRosterPortfolio } from '../lib/agentRoster.js';
|
|
40
|
+
import {
|
|
41
|
+
queryTestEvents,
|
|
42
|
+
isEventLogSeamAvailable,
|
|
43
|
+
resetTestSeam,
|
|
44
|
+
type TestEvent,
|
|
45
|
+
} from '../lib/event-log-query.js';
|
|
46
|
+
|
|
47
|
+
const ROSTER_ID_RE = /^host:[a-z0-9][a-z0-9._-]*$/;
|
|
48
|
+
|
|
49
|
+
/** Lowest-sequence event matching one of `types`; undefined when none present. */
|
|
50
|
+
function firstOf(events: TestEvent[], types: string[]): TestEvent | undefined {
|
|
51
|
+
return events
|
|
52
|
+
.filter((e) => types.includes(e.type))
|
|
53
|
+
.sort((a, b) => a.sequence - b.sequence)[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('agent-roster-attribution (RFC 0086 §B/§C)', () => {
|
|
57
|
+
it('serves the normative roster, attributes a portfolio fire content-free + ordered, and tenant-scopes', async () => {
|
|
58
|
+
const cap = await readRosterCap();
|
|
59
|
+
if (!behaviorGate('openwop-roster-attribution', cap?.supported === true)) return;
|
|
60
|
+
|
|
61
|
+
// RFC 0074 carry-forward: installScope MUST be host|tenant when present.
|
|
62
|
+
const installScope = typeof cap?.installScope === 'string' ? cap.installScope : 'host';
|
|
63
|
+
expect(
|
|
64
|
+
installScope === 'host' || installScope === 'tenant',
|
|
65
|
+
driver.describe('RFC 0086 §F / RFC 0074 §B', "agents.roster.installScope (when present) MUST be 'host' or 'tenant'"),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
|
|
68
|
+
// ---- Leg 1: normative read (black-box on any roster host) -------------
|
|
69
|
+
const body = await listRoster();
|
|
70
|
+
if (body === null) return; // host advertises roster but doesn't serve the read yet — soft-skip
|
|
71
|
+
const roster = body.roster ?? [];
|
|
72
|
+
expect(
|
|
73
|
+
Array.isArray(roster),
|
|
74
|
+
driver.describe('agent-roster.md §B', 'GET /v1/agents/roster MUST return a roster[] array'),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
expect(
|
|
77
|
+
body.total === roster.length,
|
|
78
|
+
driver.describe('agent-roster-response.schema.json', 'total MUST equal roster.length'),
|
|
79
|
+
).toBe(true);
|
|
80
|
+
for (const entry of roster) {
|
|
81
|
+
expect(
|
|
82
|
+
typeof entry.rosterId === 'string' && ROSTER_ID_RE.test(entry.rosterId),
|
|
83
|
+
driver.describe('agent-roster-entry.schema.json', 'each entry MUST carry a host:<id> rosterId'),
|
|
84
|
+
).toBe(true);
|
|
85
|
+
expect(
|
|
86
|
+
typeof entry.persona === 'string' && entry.persona.length > 0,
|
|
87
|
+
driver.describe('agent-roster.md §A', 'each entry MUST carry a non-empty persona'),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
expect(
|
|
90
|
+
typeof entry.agentRef?.agentId === 'string',
|
|
91
|
+
driver.describe('agent-roster.md §A', 'each entry MUST reference an agentRef.agentId'),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
expect(
|
|
94
|
+
typeof entry.owner?.tenantId === 'string',
|
|
95
|
+
driver.describe('agent-roster.md §B / RFC 0074', 'each entry MUST carry an owner.tenantId scope'),
|
|
96
|
+
).toBe(true);
|
|
97
|
+
// RFC 0082 §A XOR: an agentRef MUST NOT pin both version and channel.
|
|
98
|
+
expect(
|
|
99
|
+
!(entry.agentRef?.version !== undefined && entry.agentRef?.channel !== undefined),
|
|
100
|
+
driver.describe('RFC 0082 §A', 'agentRef MUST NOT carry both version and channel'),
|
|
101
|
+
).toBe(true);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- Leg 2: attribution + ordering (seam-gated) ----------------------
|
|
105
|
+
if (await isEventLogSeamAvailable()) {
|
|
106
|
+
// Scheduled portfolio fire.
|
|
107
|
+
const fired = await fireRosterPortfolio({ triggerSource: 'schedule' });
|
|
108
|
+
if (fired?.runId) {
|
|
109
|
+
const q = await queryTestEvents(fired.runId);
|
|
110
|
+
if (q.ok) {
|
|
111
|
+
const init = firstOf(q.events, ['roster.run.initiated']);
|
|
112
|
+
expect(
|
|
113
|
+
init !== undefined,
|
|
114
|
+
driver.describe('agent-roster.md §C', 'a portfolio fire MUST emit roster.run.initiated'),
|
|
115
|
+
).toBe(true);
|
|
116
|
+
|
|
117
|
+
if (init) {
|
|
118
|
+
// Ordering: roster.run.initiated precedes ANY agent invocation/event.
|
|
119
|
+
const firstAgent = firstOf(q.events, [
|
|
120
|
+
'agent.invocation.started',
|
|
121
|
+
'agent.reasoned',
|
|
122
|
+
'agent.decided',
|
|
123
|
+
]);
|
|
124
|
+
if (firstAgent) {
|
|
125
|
+
expect(
|
|
126
|
+
init.sequence < firstAgent.sequence,
|
|
127
|
+
driver.describe('agent-roster.md §C', 'roster.run.initiated MUST precede any agent.* event in the run'),
|
|
128
|
+
).toBe(true);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Content-free: required ids present; NO work-item body/prompt/credential.
|
|
132
|
+
const p = init.payload;
|
|
133
|
+
for (const key of ['rosterId', 'persona', 'agentId', 'workflowId', 'triggerSource']) {
|
|
134
|
+
expect(
|
|
135
|
+
typeof p[key] === 'string' && (p[key] as string).length > 0,
|
|
136
|
+
driver.describe('run-event-payloads.schema.json#rosterRunInitiated', `roster.run.initiated MUST carry ${key}`),
|
|
137
|
+
).toBe(true);
|
|
138
|
+
}
|
|
139
|
+
for (const forbidden of ['body', 'prompt', 'input', 'payload', 'apiKey', 'secret', 'credentials', 'token']) {
|
|
140
|
+
expect(
|
|
141
|
+
!(forbidden in p),
|
|
142
|
+
driver.describe('SECURITY roster-attribution-no-content', `roster.run.initiated MUST be content-free (no ${forbidden})`),
|
|
143
|
+
).toBe(true);
|
|
144
|
+
}
|
|
145
|
+
expect(
|
|
146
|
+
typeof p.rosterId === 'string' && ROSTER_ID_RE.test(p.rosterId),
|
|
147
|
+
driver.describe('agent-roster.md §C', 'roster.run.initiated.rosterId MUST be a host:<id> AgentRef id'),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Durable work-item fire: carries the RFC 0083 triggerSubscriptionId + causation.
|
|
154
|
+
const work = await fireRosterPortfolio({ triggerSource: 'webhook', asWorkItem: true });
|
|
155
|
+
if (work?.runId) {
|
|
156
|
+
const q = await queryTestEvents(work.runId, { type: 'roster.run.initiated' });
|
|
157
|
+
if (q.ok && q.events[0]) {
|
|
158
|
+
const p = q.events[0].payload;
|
|
159
|
+
expect(
|
|
160
|
+
typeof p.triggerSubscriptionId === 'string' && (p.triggerSubscriptionId as string).length > 0,
|
|
161
|
+
driver.describe('agent-roster.md §D / RFC 0083', 'a durable work-item fire MUST carry triggerSubscriptionId for trigger→run→roster ancestry'),
|
|
162
|
+
).toBe(true);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await resetTestSeam();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- Leg 3: tenant scoping (RFC 0074) --------------------------------
|
|
170
|
+
const crossTenantId = process.env.OPENWOP_CROSS_TENANT_ROSTER_ID;
|
|
171
|
+
if (typeof crossTenantId === 'string' && crossTenantId.length > 0) {
|
|
172
|
+
const probe = await getRosterEntry(crossTenantId);
|
|
173
|
+
expect(
|
|
174
|
+
probe.status === 404,
|
|
175
|
+
driver.describe('agent-roster.md §B / RFC 0074', "GET /v1/agents/roster/{id} for a cross-tenant id MUST 404 (no cross-tenant disclosure)"),
|
|
176
|
+
).toBe(true);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget, quota, and cost policy — policy + events + cap.breached kinds (RFC 0084).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free schema-shape probe. Verifies that:
|
|
5
|
+
* - `budget-policy.schema.json` round-trips a conforming `BudgetPolicy` and
|
|
6
|
+
* rejects the malformed (the §A orthogonality guard — a wall-time field is
|
|
7
|
+
* rejected by `additionalProperties:false`, because wall-time is RFC 0058's
|
|
8
|
+
* `runTimeoutMs`; a `thresholdPercent` out of 0..100; an out-of-enum
|
|
9
|
+
* `onExhaustion`).
|
|
10
|
+
* - the four `budget.{reserved,consumed,threshold.crossed,exhausted}` payload
|
|
11
|
+
* $defs validate conforming content-free records and reject malformed ones.
|
|
12
|
+
* - the four new `cap.breached.kind` values (`budget-tokens`/`budget-cost`/
|
|
13
|
+
* `budget-tool-calls`/`budget-retries`) are present in the enum.
|
|
14
|
+
* - the four `budget.*` event names appear in the RunEventType enum.
|
|
15
|
+
* - the `budget.*` payloads are CONTENT-FREE OF PRICING: none declares a
|
|
16
|
+
* rate-card / per-token-price / model-prose property (the public test for the
|
|
17
|
+
* protocol-tier SECURITY invariant `budget-no-pricing-leak`).
|
|
18
|
+
* - `capabilities.budget` + `limits.maxBudget{Tokens,CostUsd}` are declared.
|
|
19
|
+
*
|
|
20
|
+
* Behavioral assertions (accrue → threshold → exhaust → `cap.breached{budget-cost}`
|
|
21
|
+
* → `run.failed{budget_exhausted}`; `budget_model_denied`; the advisory no-stop
|
|
22
|
+
* path) are gated on `capabilities.budget.supported` + `enforce` and land in
|
|
23
|
+
* `budget-enforcement.test.ts` (deferred per RFC 0084 §Conformance — reference host
|
|
24
|
+
* deferred). This scenario asserts the wire contract, not host behavior.
|
|
25
|
+
*
|
|
26
|
+
* Spec references:
|
|
27
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/budget-policy.md
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0084-budget-quota-and-cost-policy.md
|
|
29
|
+
* - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (budget-no-pricing-leak)
|
|
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
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
40
|
+
|
|
41
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
42
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Property names that would betray pricing / credential prose leaking onto a budget event. */
|
|
46
|
+
const PRICING_PROP_NAMES = ['ratecard', 'pricepertoken', 'unitprice', 'pricing', 'rate', 'credential', 'apikey', 'model'];
|
|
47
|
+
|
|
48
|
+
describe('budget-policy-shape: BudgetPolicy (RFC 0084 §A, server-free)', () => {
|
|
49
|
+
const ajv = addFormats(new Ajv2020({ strict: false }));
|
|
50
|
+
const validate = ajv.compile(loadSchema('budget-policy.schema.json'));
|
|
51
|
+
|
|
52
|
+
it('a conforming budget policy validates', () => {
|
|
53
|
+
expect(
|
|
54
|
+
validate({ maxTokens: 200000, maxCostUsd: 1.0, maxToolCalls: 50, maxRetries: 10, modelAllow: ['claude-*'], modelDeny: ['gpt-4-32k'], thresholdPercent: 80, onExhaustion: 'fail' }),
|
|
55
|
+
why('budget-policy.md §A', 'a conforming BudgetPolicy MUST validate'),
|
|
56
|
+
).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('the orthogonality guard: a wall-time field is rejected (it is RFC 0058 runTimeoutMs)', () => {
|
|
60
|
+
expect(validate({ maxCostUsd: 1.0, maxWallTimeMs: 60000 }), why('budget-policy.md §A/§E', 'wall-time is NOT a budget dimension')).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects an out-of-range thresholdPercent and an out-of-enum onExhaustion', () => {
|
|
64
|
+
expect(validate({ thresholdPercent: 120 }), why('budget-policy.md §A', 'thresholdPercent MUST be 0..100')).toBe(false);
|
|
65
|
+
expect(validate({ onExhaustion: 'explode' }), why('budget-policy.md §A', 'onExhaustion is a closed enum')).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('budget-policy-shape: budget.* events + cap.breached kinds (RFC 0084 §C/§D, server-free)', () => {
|
|
70
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
71
|
+
const ajv = addFormats(new Ajv2020({ strict: false }));
|
|
72
|
+
const compile = (defName: string) => ajv.compile({
|
|
73
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
74
|
+
$defs: (payloads as { $defs: Record<string, unknown> }).$defs,
|
|
75
|
+
$ref: `#/$defs/${defName}`,
|
|
76
|
+
} as Record<string, unknown>);
|
|
77
|
+
|
|
78
|
+
it('the four budget.* payloads validate conforming content-free records', () => {
|
|
79
|
+
expect(compile('budgetReserved')({ effectiveBudget: { maxCostUsd: 1.0 }, scope: 'run' }), why('budget-policy.md §C', 'budget.reserved MUST validate')).toBe(true);
|
|
80
|
+
expect(compile('budgetConsumed')({ dimension: 'cost', consumed: 0.7, limit: 1.0, remaining: 0.3 }), why('budget-policy.md §C', 'budget.consumed MUST validate')).toBe(true);
|
|
81
|
+
expect(compile('budgetThresholdCrossed')({ dimension: 'cost', consumed: 0.8, limit: 1.0, percent: 80 }), why('budget-policy.md §C', 'budget.threshold.crossed MUST validate')).toBe(true);
|
|
82
|
+
expect(compile('budgetExhausted')({ dimension: 'cost', consumed: 1.02, limit: 1.0 }), why('budget-policy.md §C', 'budget.exhausted MUST validate')).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects an out-of-enum dimension and a missing required field', () => {
|
|
86
|
+
expect(compile('budgetConsumed')({ dimension: 'vibes', consumed: 1, limit: 2 }), why('budget-policy.md §C', 'dimension is a closed enum')).toBe(false);
|
|
87
|
+
expect(compile('budgetExhausted')({ dimension: 'cost', consumed: 1.0 }), why('budget-policy.md §C', 'limit is REQUIRED')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('the cap.breached kind enum carries the four budget-* values', () => {
|
|
91
|
+
const kinds = ((payloads.$defs as Record<string, { properties?: Record<string, { enum?: string[] }> }>).capBreached.properties?.kind?.enum) ?? [];
|
|
92
|
+
for (const k of ['budget-tokens', 'budget-cost', 'budget-tool-calls', 'budget-retries']) {
|
|
93
|
+
expect(kinds.includes(k), why('budget-policy.md §D', `cap.breached.kind MUST include ${k}`)).toBe(true);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('all four budget.* event names appear in the RunEventType enum', () => {
|
|
98
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
99
|
+
const enumVals = ((runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum) ?? [];
|
|
100
|
+
for (const name of ['budget.reserved', 'budget.consumed', 'budget.threshold.crossed', 'budget.exhausted']) {
|
|
101
|
+
expect(enumVals.includes(name), why('run-event.schema.json', `${name} MUST be in the RunEventType enum`)).toBe(true);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('the budget.* payloads declare no pricing/credential property (budget-no-pricing-leak)', () => {
|
|
106
|
+
const defs = payloads.$defs as Record<string, { properties?: Record<string, unknown> }>;
|
|
107
|
+
for (const def of ['budgetReserved', 'budgetConsumed', 'budgetThresholdCrossed', 'budgetExhausted']) {
|
|
108
|
+
for (const p of Object.keys(defs[def].properties ?? {})) {
|
|
109
|
+
expect(PRICING_PROP_NAMES.includes(p.toLowerCase()), why('budget-no-pricing-leak', `${def} MUST NOT declare a pricing-bearing property (${p})`)).toBe(false);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('the budget.* payloads are additionalProperties:false — a rate-card field on an INSTANCE is rejected', () => {
|
|
115
|
+
// The aggregate cost total (the user's own budget) is permitted; the host's per-unit rate card is not.
|
|
116
|
+
// additionalProperties:false makes the rejection structural, not just a declared-property check.
|
|
117
|
+
expect(compile('budgetConsumed')({ dimension: 'cost', consumed: 0.8, limit: 1.0 }), why('budget-policy.md §F', 'an aggregate cost total (the user budget) MUST validate')).toBe(true);
|
|
118
|
+
expect(
|
|
119
|
+
compile('budgetConsumed')({ dimension: 'cost', consumed: 0.8, limit: 1.0, ratePerToken: 0.000003 }),
|
|
120
|
+
why('budget-no-pricing-leak', 'a rate-card / per-token-price field MUST be rejected (additionalProperties:false)'),
|
|
121
|
+
).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('budget-policy-shape: capability advertisement (RFC 0084 §E, server-free)', () => {
|
|
126
|
+
it('capabilities.budget + limits.maxBudget{Tokens,CostUsd} are declared', () => {
|
|
127
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
128
|
+
const props = caps.properties as Record<string, { properties?: Record<string, unknown> }>;
|
|
129
|
+
for (const flag of ['supported', 'dimensions', 'enforce', 'scopes']) {
|
|
130
|
+
expect(props.budget?.properties?.[flag], why('budget-policy.md §E', `capabilities.budget.${flag} MUST be declared`)).toBeDefined();
|
|
131
|
+
}
|
|
132
|
+
for (const ceiling of ['maxBudgetTokens', 'maxBudgetCostUsd']) {
|
|
133
|
+
expect(props.limits?.properties?.[ceiling], why('budget-policy.md §E', `limits.${ceiling} MUST be declared`)).toBeDefined();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|