@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,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/trigger-subscription.schema.json",
|
|
4
|
+
"title": "TriggerSubscription",
|
|
5
|
+
"description": "RFC 0083 §B. A durable inbound-trigger subscription record (a webhook registration, a schedule, a queue consumer) with a standardized four-state machine layered over the existing per-source registration. Composes RFC 0052/0053/0017 + webhooks.md + RFC 0040 causation; the channel wire format stays a vendor extension (§E). Content-free of inbound payloads/credentials (SR-1).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["subscriptionId", "source", "state"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"subscriptionId": { "type": "string", "minLength": 1, "description": "Stable host-unique id for the subscription. Correlates the §C delivery events + the management surface." },
|
|
11
|
+
"source": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form"], "description": "Which trigger source backs the subscription. Channels beyond these (Slack/Discord/SMS) bridge as a vendor extension by registering a subscription of the closest source kind (§E)." },
|
|
12
|
+
"state": { "type": "string", "enum": ["active", "paused", "failed", "dead-lettered"], "description": "The §B state. `active`: accepting + delivering; `paused`: retained, not delivering (operator-held); `failed`: delivery failing past policy (the webhooks.md circuit-breaker generalized); `dead-lettered`: terminal, deliveries routed to the RFC 0053 sink." },
|
|
13
|
+
"dedupEnabled": { "type": "boolean", "description": "When true, the host de-duplicates inbound events by `dedupKey` within the retention window (§C-1; the idempotency.md Layer-1 model applied to inbound triggers)." },
|
|
14
|
+
"retryPolicy": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"description": "Delivery retry policy (§C-2). On exhaustion the subscription/delivery transitions to `dead-lettered` (RFC 0053).",
|
|
18
|
+
"properties": {
|
|
19
|
+
"maxAttempts": { "type": "integer", "minimum": 1, "description": "Maximum delivery attempts before dead-lettering." },
|
|
20
|
+
"backoff": { "type": "string", "enum": ["none", "fixed", "exponential"], "description": "Backoff strategy between attempts." }
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"webhookId": { "type": "string", "minLength": 1, "description": "MAY — for `source: \"webhook\"`, the existing webhooks.md register key (unchanged; the state machine layers over it)." },
|
|
24
|
+
"secretFingerprint": { "type": "string", "minLength": 1, "maxLength": 32, "description": "MAY — for `source: \"webhook\"`, an identifier for the signing secret (the `(webhookId, secretFingerprint)` register key). It MUST be a **salted or host-keyed, TRUNCATED** one-way digest (e.g. the first 8–16 hex of `HMAC(hostKey, secret)`) — NOT the raw secret (SR-1) and NOT a full unsalted `SHA256(secret)` (a full unsalted hash of a low-entropy secret is an offline brute-force / confirmation oracle). The `maxLength: 32` ceiling structurally rejects a full 64-hex digest." }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the RFC 0086 `agents.roster` conformance scenarios.
|
|
3
|
+
* Lives in lib/ (not a `*.test.ts`) so scenarios import it via
|
|
4
|
+
* `../lib/agentRoster.js`.
|
|
5
|
+
*
|
|
6
|
+
* Two surfaces:
|
|
7
|
+
* - the NORMATIVE read (`GET /v1/agents/roster[/{rosterId}]`, RFC 0086 §B),
|
|
8
|
+
* exercised black-box against any conformant host; and
|
|
9
|
+
* - the host-sample fire seam (`POST /v1/host/sample/roster/fire`), used to
|
|
10
|
+
* drive a portfolio trigger so the `roster.run.initiated` attribution +
|
|
11
|
+
* ordering can be asserted against the test event-log seam. The fire seam
|
|
12
|
+
* is OPTIONAL — scenarios soft-skip on 404/405 (the reference roster store
|
|
13
|
+
* is deferred per RFC 0086 §Conformance).
|
|
14
|
+
*
|
|
15
|
+
* @see RFCS/0086-standing-agent-roster-and-workflow-portfolio.md
|
|
16
|
+
* @see spec/v1/agent-roster.md
|
|
17
|
+
*/
|
|
18
|
+
import { driver } from './driver.js';
|
|
19
|
+
import { readCapabilityFamily } from './discovery-capabilities.js';
|
|
20
|
+
|
|
21
|
+
/** Reads `agents.roster` from discovery (root-first per RFC 0073); null when
|
|
22
|
+
* unadvertised. */
|
|
23
|
+
export async function readRosterCap(): Promise<Record<string, unknown> | null> {
|
|
24
|
+
const agents = await readCapabilityFamily<{ roster?: unknown }>('agents');
|
|
25
|
+
const r = agents?.roster;
|
|
26
|
+
return r && typeof r === 'object' ? (r as Record<string, unknown>) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RosterEntry {
|
|
30
|
+
rosterId?: string;
|
|
31
|
+
persona?: string;
|
|
32
|
+
agentRef?: { agentId?: string; version?: string; channel?: string };
|
|
33
|
+
workflows?: string[];
|
|
34
|
+
owner?: { tenantId?: string; workspaceId?: string };
|
|
35
|
+
[k: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RosterResponse {
|
|
39
|
+
roster?: RosterEntry[];
|
|
40
|
+
total?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** GET the NORMATIVE standing roster (RFC 0086 §B `GET /v1/agents/roster`);
|
|
44
|
+
* null when the host doesn't serve it (404/405/501). */
|
|
45
|
+
export async function listRoster(): Promise<RosterResponse | null> {
|
|
46
|
+
const res = await driver.get('/v1/agents/roster');
|
|
47
|
+
if (res.status === 404 || res.status === 405 || res.status === 501) return null;
|
|
48
|
+
return (res.json as RosterResponse | undefined) ?? {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** GET a single roster entry by id. Returns `{ status, entry }` so a caller can
|
|
52
|
+
* distinguish a 404 (cross-tenant / unknown) from a served entry. */
|
|
53
|
+
export async function getRosterEntry(
|
|
54
|
+
rosterId: string,
|
|
55
|
+
): Promise<{ status: number; entry: RosterEntry | undefined }> {
|
|
56
|
+
const res = await driver.get(`/v1/agents/roster/${encodeURIComponent(rosterId)}`);
|
|
57
|
+
return { status: res.status, entry: res.json as RosterEntry | undefined };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RosterFireResult {
|
|
61
|
+
runId?: string;
|
|
62
|
+
rosterId?: string;
|
|
63
|
+
triggerSubscriptionId?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Drive a portfolio trigger for a roster member via the host-sample fire seam.
|
|
67
|
+
* `asWorkItem:true` requests the RFC 0083 durable-work-item path (carries a
|
|
68
|
+
* `triggerSubscriptionId` + run `causationId`). Returns null when the seam is
|
|
69
|
+
* unwired (404/405). */
|
|
70
|
+
export async function fireRosterPortfolio(
|
|
71
|
+
body: { rosterId?: string; triggerSource?: string; asWorkItem?: boolean } = {},
|
|
72
|
+
): Promise<RosterFireResult | null> {
|
|
73
|
+
const res = await driver.post('/v1/host/sample/roster/fire', body);
|
|
74
|
+
if (res.status === 404 || res.status === 405) return null;
|
|
75
|
+
return (res.json as RosterFireResult | undefined) ?? {};
|
|
76
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the RFC 0077 `agents.liveRuntime` conformance scenarios.
|
|
3
|
+
* Lives in lib/ (not a `*.test.ts`) so scenarios import it via
|
|
4
|
+
* `../lib/liveRuntime.js`.
|
|
5
|
+
*
|
|
6
|
+
* RFC 0077 adds NO new endpoint — a live manifest invocation rides the existing
|
|
7
|
+
* run surface (agent as root of `POST /v1/runs`, a `WorkflowNode.agent` step, or
|
|
8
|
+
* a chat `@mention`) and brackets the existing `agent.*` family with
|
|
9
|
+
* `agent.invocation.started` / `agent.invocation.completed`. To drive one
|
|
10
|
+
* deterministically in conformance, the host exposes the OPTIONAL sample seam
|
|
11
|
+
* `POST /v1/host/sample/agents/live-invoke` returning `{ runId, invocationId }`;
|
|
12
|
+
* the bracketed events are read back via the test event-log seam. The seam is
|
|
13
|
+
* deferred per RFC 0077 §Conformance, so scenarios soft-skip on 404/405.
|
|
14
|
+
*
|
|
15
|
+
* @see RFCS/0077-agent-run-lifecycle-and-live-manifest-dispatch.md
|
|
16
|
+
* @see spec/v1/multi-agent-execution.md §"Live manifest dispatch"
|
|
17
|
+
*/
|
|
18
|
+
import { driver } from './driver.js';
|
|
19
|
+
import { readCapabilityFamily } from './discovery-capabilities.js';
|
|
20
|
+
|
|
21
|
+
/** Reads `agents.liveRuntime` from discovery (root-first per RFC 0073); null
|
|
22
|
+
* when unadvertised. */
|
|
23
|
+
export async function readLiveRuntimeCap(): Promise<Record<string, unknown> | null> {
|
|
24
|
+
const agents = await readCapabilityFamily<{ liveRuntime?: unknown }>('agents');
|
|
25
|
+
const lr = agents?.liveRuntime;
|
|
26
|
+
return lr && typeof lr === 'object' ? (lr as Record<string, unknown>) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LiveInvokeResult {
|
|
30
|
+
runId?: string;
|
|
31
|
+
invocationId?: string;
|
|
32
|
+
outcome?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Drive one live manifest invocation via the host-sample seam. Body fields:
|
|
37
|
+
* - `agentId` (optional): the manifest agent to invoke; host picks a default
|
|
38
|
+
* when omitted.
|
|
39
|
+
* - `source` (optional): `workflow-node` | `run-api` | `chat-mention`.
|
|
40
|
+
* - `returnSchemaRef` (optional) + `forceInvalidResult` (optional): exercise
|
|
41
|
+
* the §B step-6 structured-output enforcement — force a result that violates
|
|
42
|
+
* the handoff schema so a `structuredOutput` host fails the run.
|
|
43
|
+
* - `attemptTool` (optional): the id of a tool OUTSIDE the agent's
|
|
44
|
+
* `toolAllowlist` the invocation should attempt (the §F-1 allowlist floor).
|
|
45
|
+
* Returns null when the seam is unwired (404/405).
|
|
46
|
+
*/
|
|
47
|
+
export async function invokeLive(
|
|
48
|
+
body: {
|
|
49
|
+
agentId?: string;
|
|
50
|
+
source?: string;
|
|
51
|
+
returnSchemaRef?: string;
|
|
52
|
+
forceInvalidResult?: boolean;
|
|
53
|
+
attemptTool?: string;
|
|
54
|
+
} = {},
|
|
55
|
+
): Promise<LiveInvokeResult | null> {
|
|
56
|
+
const res = await driver.post('/v1/host/sample/agents/live-invoke', body);
|
|
57
|
+
if (res.status === 404 || res.status === 405) return null;
|
|
58
|
+
return (res.json as LiveInvokeResult | undefined) ?? {};
|
|
59
|
+
}
|
package/src/lib/profiles.ts
CHANGED
|
@@ -30,6 +30,8 @@ export const PROFILE_NAMES = [
|
|
|
30
30
|
'openwop-node-packs',
|
|
31
31
|
'openwop-replay-fork',
|
|
32
32
|
'openwop-fixtures',
|
|
33
|
+
'openwop-memory',
|
|
34
|
+
'openwop-trigger-bridge',
|
|
33
35
|
] as const;
|
|
34
36
|
|
|
35
37
|
export type ProfileName = (typeof PROFILE_NAMES)[number];
|
|
@@ -211,6 +213,155 @@ export function isFixtures(c: DiscoveryPayload): boolean {
|
|
|
211
213
|
return c.fixtures.every((id) => typeof id === 'string' && id.length > 0);
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
/**
|
|
217
|
+
* `openwop-memory` predicate (RFC 0080). Host implements the reconciled
|
|
218
|
+
* memory-capability model at the core tier: a read/write `MemoryAdapter`
|
|
219
|
+
* (`memory.supported: true` and `memory.writable !== false`) plus a cross-run
|
|
220
|
+
* durable store (`agents.memoryBackends` includes `'long-term'`). Capability
|
|
221
|
+
* families are document-root properties of the discovery payload (RFC 0073),
|
|
222
|
+
* so this reads `c.memory` / `c.agents`, matching `isReplayFork`.
|
|
223
|
+
*
|
|
224
|
+
* @see spec/v1/profiles.md §`openwop-memory`
|
|
225
|
+
* @see spec/v1/agent-memory.md §"Memory capability model"
|
|
226
|
+
*/
|
|
227
|
+
export function isMemory(c: DiscoveryPayload): boolean {
|
|
228
|
+
if (!isCore(c)) return false;
|
|
229
|
+
const memory = c.memory as { supported?: unknown; writable?: unknown } | undefined;
|
|
230
|
+
if (memory == null || typeof memory !== 'object') return false;
|
|
231
|
+
if (memory.supported !== true) return false;
|
|
232
|
+
if (memory.writable === false) return false;
|
|
233
|
+
const agents = c.agents as { memoryBackends?: unknown } | undefined;
|
|
234
|
+
if (agents == null || !isStringArray(agents.memoryBackends)) return false;
|
|
235
|
+
return agents.memoryBackends.includes('long-term');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `openwop-trigger-bridge` predicate (RFC 0083). Host composes the durable
|
|
240
|
+
* inbound-work contract: advertises the `triggerBridge`, has a `deadLetter`
|
|
241
|
+
* sink for exhausted deliveries, and has at least one durable inbound source
|
|
242
|
+
* (queue bus, durable webhooks, or scheduling). Capability families are
|
|
243
|
+
* document-root properties (RFC 0073), so this reads `c.triggerBridge` /
|
|
244
|
+
* `c.deadLetter` / `c.queueBus` / `c.webhooks` / `c.scheduling`.
|
|
245
|
+
*
|
|
246
|
+
* @see spec/v1/profiles.md §`openwop-trigger-bridge`
|
|
247
|
+
* @see spec/v1/trigger-bridge.md
|
|
248
|
+
*/
|
|
249
|
+
export function isTriggerBridge(c: DiscoveryPayload): boolean {
|
|
250
|
+
if (!isCore(c)) return false;
|
|
251
|
+
const supported = (v: unknown): boolean =>
|
|
252
|
+
v != null && typeof v === 'object' && (v as { supported?: unknown }).supported === true;
|
|
253
|
+
if (!supported(c.triggerBridge)) return false;
|
|
254
|
+
if (!supported(c.deadLetter)) return false;
|
|
255
|
+
const webhooks = c.webhooks as { durable?: unknown } | undefined;
|
|
256
|
+
const durableSource =
|
|
257
|
+
supported(c.queueBus) ||
|
|
258
|
+
supported(c.scheduling) ||
|
|
259
|
+
(webhooks != null && typeof webhooks === 'object' && webhooks.durable === true);
|
|
260
|
+
return durableSource;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
264
|
+
// Operational annex: openwop-agent-platform (RFC 0085).
|
|
265
|
+
//
|
|
266
|
+
// NOT part of the closed `profiles.md` predicate catalog (PROFILE_NAMES /
|
|
267
|
+
// deriveProfiles above) — it is an operational ANNEX (the production-profile.md /
|
|
268
|
+
// auth-profiles.md pattern) combining a discovery predicate with required runtime
|
|
269
|
+
// conformance evidence + documentation + a badge. These helpers compute only the
|
|
270
|
+
// discovery-PREDICATE part; the live aggregate-evidence assertion (does every
|
|
271
|
+
// constituent scenario actually pass?) lives in agent-platform-profile.test.ts.
|
|
272
|
+
//
|
|
273
|
+
// @see spec/v1/agent-platform-profile.md
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/** Narrow helper: a capability sub-block with `supported === true`. */
|
|
277
|
+
function blockSupported(v: unknown): boolean {
|
|
278
|
+
return v != null && typeof v === 'object' && (v as { supported?: unknown }).supported === true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The `openwop-agent-platform` FLOOR (`partial`) discovery predicate — RFC 0085 §B. */
|
|
282
|
+
export function isAgentPlatformPartial(c: DiscoveryPayload): boolean {
|
|
283
|
+
if (!isCore(c)) return false;
|
|
284
|
+
const agents = c.agents as { manifestRuntime?: unknown; liveRuntime?: unknown } | undefined;
|
|
285
|
+
const httpClient = c.httpClient as { safeFetch?: unknown; egressPolicy?: unknown } | undefined;
|
|
286
|
+
const replay = c.replay as { supported?: unknown } | undefined;
|
|
287
|
+
const nondet = c.nondeterminismPolicy as { declared?: unknown } | undefined;
|
|
288
|
+
return (
|
|
289
|
+
blockSupported(agents?.manifestRuntime) &&
|
|
290
|
+
blockSupported(agents?.liveRuntime) &&
|
|
291
|
+
blockSupported(c.toolCatalog) &&
|
|
292
|
+
blockSupported(c.toolHooks) &&
|
|
293
|
+
blockSupported(httpClient?.safeFetch) &&
|
|
294
|
+
blockSupported(c.providerUsage) &&
|
|
295
|
+
blockSupported(c.prompts) &&
|
|
296
|
+
blockSupported(c.memory) &&
|
|
297
|
+
blockSupported(c.feedback) &&
|
|
298
|
+
(replay?.supported === true || nondet?.declared === true)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The `openwop-agent-platform` `full` discovery predicate (floor + governance tier) — RFC 0085 §B. */
|
|
303
|
+
export function isAgentPlatformFull(c: DiscoveryPayload): boolean {
|
|
304
|
+
if (!isAgentPlatformPartial(c)) return false;
|
|
305
|
+
const agents = c.agents as { manifestRuntime?: { installScope?: unknown } } | undefined;
|
|
306
|
+
const memory = c.memory as { attribution?: unknown } | undefined;
|
|
307
|
+
// Debug bundle is advertised at `capabilities.debugBundle.supported` (debug-bundle.md /
|
|
308
|
+
// RFC 0009), NOT under `production.*` — the production block only adds stricter truncation MUSTs.
|
|
309
|
+
const httpClient = c.httpClient as { egressPolicy?: unknown } | undefined;
|
|
310
|
+
return (
|
|
311
|
+
blockSupported(c.authorization) &&
|
|
312
|
+
agents?.manifestRuntime?.installScope === 'tenant' &&
|
|
313
|
+
blockSupported(memory?.attribution) &&
|
|
314
|
+
blockSupported(c.debugBundle) &&
|
|
315
|
+
blockSupported(c.triggerBridge) &&
|
|
316
|
+
blockSupported(httpClient?.egressPolicy)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The host-reported annex status: `full` ⊃ `partial` ⊃ `none` (discovery-predicate only). */
|
|
321
|
+
export function agentPlatformStatus(c: DiscoveryPayload): 'none' | 'partial' | 'full' {
|
|
322
|
+
if (isAgentPlatformFull(c)) return 'full';
|
|
323
|
+
if (isAgentPlatformPartial(c)) return 'partial';
|
|
324
|
+
return 'none';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The per-term satisfaction breakdown (RFC 0085 §D) — the richer interop signal
|
|
329
|
+
* alongside the flat `none`/`partial`/`full` ladder. Adoption is NON-CONTIGUOUS:
|
|
330
|
+
* a real host built feature-by-feature can satisfy `full`-tier terms (RBAC,
|
|
331
|
+
* memory-attribution, tenant-scoping) while still failing `floor` terms, so the
|
|
332
|
+
* flat status would understate it (reads identical to a do-nothing host). This
|
|
333
|
+
* returns exactly the term ids a host satisfies, so a `none` host honoring 6/16
|
|
334
|
+
* terms is distinguishable from one honoring 0/16.
|
|
335
|
+
*/
|
|
336
|
+
export function agentPlatformSatisfiedTerms(c: DiscoveryPayload): readonly string[] {
|
|
337
|
+
const agents = c.agents as { manifestRuntime?: { installScope?: unknown }; liveRuntime?: unknown } | undefined;
|
|
338
|
+
const httpClient = c.httpClient as { safeFetch?: unknown; egressPolicy?: unknown } | undefined;
|
|
339
|
+
const memory = c.memory as { attribution?: unknown } | undefined;
|
|
340
|
+
const replay = c.replay as { supported?: unknown } | undefined;
|
|
341
|
+
const nondet = c.nondeterminismPolicy as { declared?: unknown } | undefined;
|
|
342
|
+
const checks: ReadonlyArray<readonly [string, boolean]> = [
|
|
343
|
+
// floor
|
|
344
|
+
['floor:agents.manifestRuntime', blockSupported(agents?.manifestRuntime)],
|
|
345
|
+
['floor:agents.liveRuntime', blockSupported(agents?.liveRuntime)],
|
|
346
|
+
['floor:toolCatalog', blockSupported(c.toolCatalog)],
|
|
347
|
+
['floor:toolHooks', blockSupported(c.toolHooks)],
|
|
348
|
+
['floor:httpClient.safeFetch', blockSupported(httpClient?.safeFetch)],
|
|
349
|
+
['floor:providerUsage', blockSupported(c.providerUsage)],
|
|
350
|
+
['floor:prompts', blockSupported(c.prompts)],
|
|
351
|
+
['floor:memory', blockSupported(c.memory)],
|
|
352
|
+
['floor:feedback', blockSupported(c.feedback)],
|
|
353
|
+
['floor:replay-or-nondeterminism', replay?.supported === true || nondet?.declared === true],
|
|
354
|
+
// full (governance)
|
|
355
|
+
['full:authorization', blockSupported(c.authorization)],
|
|
356
|
+
['full:tenant-installScope', agents?.manifestRuntime?.installScope === 'tenant'],
|
|
357
|
+
['full:memory.attribution', blockSupported(memory?.attribution)],
|
|
358
|
+
['full:debugBundle', blockSupported(c.debugBundle)],
|
|
359
|
+
['full:triggerBridge', blockSupported(c.triggerBridge)],
|
|
360
|
+
['full:egressPolicy', blockSupported(httpClient?.egressPolicy)],
|
|
361
|
+
];
|
|
362
|
+
return checks.filter(([, ok]) => ok).map(([id]) => id);
|
|
363
|
+
}
|
|
364
|
+
|
|
214
365
|
/**
|
|
215
366
|
* Derive the full profile set from a discovery payload.
|
|
216
367
|
*
|
|
@@ -228,6 +379,8 @@ export function deriveProfiles(c: DiscoveryPayload): readonly ProfileName[] {
|
|
|
228
379
|
if (isNodePacksDiscovery(c)) result.push('openwop-node-packs');
|
|
229
380
|
if (isReplayFork(c)) result.push('openwop-replay-fork');
|
|
230
381
|
if (isFixtures(c)) result.push('openwop-fixtures');
|
|
382
|
+
if (isMemory(c)) result.push('openwop-memory');
|
|
383
|
+
if (isTriggerBridge(c)) result.push('openwop-trigger-bridge');
|
|
231
384
|
return result;
|
|
232
385
|
}
|
|
233
386
|
|
|
@@ -254,5 +407,9 @@ export function hasProfile(c: DiscoveryPayload, profile: ProfileName): boolean {
|
|
|
254
407
|
return isReplayFork(c);
|
|
255
408
|
case 'openwop-fixtures':
|
|
256
409
|
return isFixtures(c);
|
|
410
|
+
case 'openwop-memory':
|
|
411
|
+
return isMemory(c);
|
|
412
|
+
case 'openwop-trigger-bridge':
|
|
413
|
+
return isTriggerBridge(c);
|
|
257
414
|
}
|
|
258
415
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper for the RFC 0076 §A `runtime.requires[]` install-gate
|
|
3
|
+
* conformance scenarios. Lives in lib/ (not a *.test.ts) so scenarios import it
|
|
4
|
+
* via `../lib/runtimeRequires.js`.
|
|
5
|
+
*
|
|
6
|
+
* Drives the conformance-only host seam specified in host-sample-test-seams.md
|
|
7
|
+
* §"Open seams": `POST /v1/host/sample/packs/install-gate`. The seam evaluates a
|
|
8
|
+
* manifest's `runtime.requires[]` against a simulated host grant-set and returns
|
|
9
|
+
* the install-time outcome the host would produce — letting a single seam
|
|
10
|
+
* exercise the grant / refuse / non-sandbox-projection behaviors deterministically.
|
|
11
|
+
*/
|
|
12
|
+
import { driver } from './driver.js';
|
|
13
|
+
|
|
14
|
+
export interface InstallGateRequest {
|
|
15
|
+
/** The candidate pack manifest (carrying runtime.requires[]). */
|
|
16
|
+
manifest: Record<string, unknown>;
|
|
17
|
+
/** Primitives the simulated sandbox grants. Ignored when `gating === false`. */
|
|
18
|
+
grantSet?: string[];
|
|
19
|
+
/** Whether the simulated host gates platform access. Default true (sandbox host). */
|
|
20
|
+
gating?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface InstallGateResponse {
|
|
24
|
+
/** HTTP status the seam returned (200 install, 400 refuse). */
|
|
25
|
+
status: number;
|
|
26
|
+
/** Parsed response body. */
|
|
27
|
+
body: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Drives one install-gate evaluation via the host-sample seam, or null
|
|
32
|
+
* (soft-skip) when the host doesn't expose it.
|
|
33
|
+
*/
|
|
34
|
+
export async function installGate(req: InstallGateRequest): Promise<InstallGateResponse | null> {
|
|
35
|
+
const res = await driver.post('/v1/host/sample/packs/install-gate', req as unknown as Record<string, unknown>);
|
|
36
|
+
if (res.status === 404 || res.status === 405) return null; // seam absent — soft-skip
|
|
37
|
+
return { status: res.status, body: (res.json as Record<string, unknown> | undefined) ?? {} };
|
|
38
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper for the RFC 0076 §B `ctx.http.safeFetch` conformance scenarios.
|
|
3
|
+
* Lives in lib/ (not a *.test.ts) so scenarios import it via `../lib/safeFetch.js`.
|
|
4
|
+
*
|
|
5
|
+
* Reads `capabilities.httpClient.safeFetch` (root-first, wrapper-fallback) and
|
|
6
|
+
* drives the conformance-only host seam `POST /v1/host/sample/http/safe-fetch`
|
|
7
|
+
* (host-sample-test-seams.md §"Open seams").
|
|
8
|
+
*/
|
|
9
|
+
import { driver } from './driver.js';
|
|
10
|
+
import { capabilityFamily } from './discovery-capabilities.js';
|
|
11
|
+
|
|
12
|
+
interface HttpClientCap {
|
|
13
|
+
supported?: boolean;
|
|
14
|
+
safeFetch?: { supported?: boolean };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True when the host advertises `capabilities.httpClient.safeFetch.supported`. */
|
|
18
|
+
export async function isSafeFetchSupported(): Promise<boolean> {
|
|
19
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
20
|
+
return capabilityFamily<HttpClientCap>(disco.json, 'httpClient')?.safeFetch?.supported === true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True when the host also advertises `capabilities.toolHooks.prePostEvents`. */
|
|
24
|
+
export async function isToolHookAuditOn(): Promise<boolean> {
|
|
25
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
26
|
+
return capabilityFamily<{ prePostEvents?: boolean }>(disco.json, 'toolHooks')?.prePostEvents === true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SafeFetchResult {
|
|
30
|
+
outcome?: 'fetched' | 'blocked';
|
|
31
|
+
status?: number;
|
|
32
|
+
blocked?: 'ssrf' | 'upgrade' | string;
|
|
33
|
+
toolCalled?: Record<string, unknown>;
|
|
34
|
+
toolReturned?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Drives one safeFetch evaluation via the host-sample seam, or null (soft-skip)
|
|
39
|
+
* when the host doesn't expose it.
|
|
40
|
+
*/
|
|
41
|
+
export async function safeFetch(body: Record<string, unknown>): Promise<SafeFetchResult | null> {
|
|
42
|
+
const res = await driver.post('/v1/host/sample/http/safe-fetch', body);
|
|
43
|
+
if (res.status === 404 || res.status === 405) return null; // seam absent — soft-skip
|
|
44
|
+
return (res.json as SafeFetchResult | undefined) ?? {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True when the host advertises BOTH `httpClient.safeFetch.supported` AND
|
|
49
|
+
* `toolHooks.prePostEvents` — the co-advertisement that, per
|
|
50
|
+
* `host-capabilities.md` §host.http + RFC 0076 §B, makes live audit-pair
|
|
51
|
+
* emission a MUST. One discovery fetch (the two single-flag helpers above each
|
|
52
|
+
* fetch; this avoids the double round-trip for the live-audit gate).
|
|
53
|
+
*/
|
|
54
|
+
export async function isSafeFetchLiveAuditAdvertised(): Promise<boolean> {
|
|
55
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
56
|
+
const safeFetchOn =
|
|
57
|
+
capabilityFamily<HttpClientCap>(disco.json, 'httpClient')?.safeFetch?.supported === true;
|
|
58
|
+
const auditOn =
|
|
59
|
+
capabilityFamily<{ prePostEvents?: boolean }>(disco.json, 'toolHooks')?.prePostEvents === true;
|
|
60
|
+
return safeFetchOn && auditOn;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Result of the live-run safe-fetch seam: the host executed one
|
|
64
|
+
* `ctx.http.safeFetch` call inside a real run via the production injection
|
|
65
|
+
* path, and returns the run's id so the caller can read the durable event
|
|
66
|
+
* log. `null` ⇒ the run seam is unwired (soft-skip, host-pending). */
|
|
67
|
+
export interface SafeFetchRunResult {
|
|
68
|
+
runId?: string;
|
|
69
|
+
outcome?: 'fetched' | 'blocked';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Drives one `ctx.http.safeFetch` call **inside a real run** via the open seam
|
|
74
|
+
* `POST /v1/host/sample/http/safe-fetch-run`, returning `{ runId, outcome }`,
|
|
75
|
+
* or null (soft-skip) when the run seam isn't wired. Distinct from `safeFetch`
|
|
76
|
+
* (which returns the audit pair INLINE from the seam): this exercises the
|
|
77
|
+
* production per-ctx `ctx.http.safeFetch` path so the caller can assert the
|
|
78
|
+
* `agent.toolCalled`/`agent.toolReturned` pair landed in the DURABLE run event
|
|
79
|
+
* log — closing the seam-vs-production gap in `safefetch-behavior.test.ts`.
|
|
80
|
+
*/
|
|
81
|
+
export async function safeFetchViaRun(
|
|
82
|
+
body: Record<string, unknown>,
|
|
83
|
+
): Promise<SafeFetchRunResult | null> {
|
|
84
|
+
const res = await driver.post('/v1/host/sample/http/safe-fetch-run', body);
|
|
85
|
+
if (res.status === 404 || res.status === 405) return null; // run seam unwired — soft-skip
|
|
86
|
+
return (res.json as SafeFetchRunResult | undefined) ?? {};
|
|
87
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent deployment lifecycle — record + binding + event shapes (RFC 0082).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free schema-shape probe. Verifies that:
|
|
5
|
+
* - `capabilities.agents.deployment` is declared with its `supported` /
|
|
6
|
+
* `channels` / `canary` / `rollback` / `states` sub-flags.
|
|
7
|
+
* - `agent-deployment.schema.json` compiles and round-trips a conforming
|
|
8
|
+
* deployment record, and rejects malformed ones (an out-of-enum `state`;
|
|
9
|
+
* `canaryPercent` out of 0..100).
|
|
10
|
+
* - the `AgentRef` `channel` XOR `version` rule holds: each alone (and
|
|
11
|
+
* neither) validates; both together is rejected (the `not` clause).
|
|
12
|
+
* - the four `deployment.*` payload $defs validate conforming content-free
|
|
13
|
+
* payloads and reject malformed ones.
|
|
14
|
+
* - the four `deployment.*` payloads are CONTENT-FREE: a `deployment.promoted`
|
|
15
|
+
* carrying a `manifestBody`, and a `deployment.state.changed` carrying a
|
|
16
|
+
* `prompt`, are rejected (`additionalProperties:false`). This is the public
|
|
17
|
+
* test for the protocol-tier SECURITY invariant `deployment-event-no-content-leak`.
|
|
18
|
+
* - `agent.invocation.started` carries the additive recorded-fact
|
|
19
|
+
* `resolvedAgentVersion` / `resolvedChannel` fields (RFC 0082 §B).
|
|
20
|
+
* - all four event names appear in the RunEventType enum.
|
|
21
|
+
*
|
|
22
|
+
* Behavioral assertions (the authz → approvalGate → eval-verify → promotion path,
|
|
23
|
+
* the fail-closed denial, the §B replay re-read of `resolvedAgentVersion`) are
|
|
24
|
+
* gated on `capabilities.agents.deployment.supported` and land in
|
|
25
|
+
* `agent-deployment-lifecycle.test.ts` (deferred per RFC 0082 §Conformance —
|
|
26
|
+
* reference host deferred). This scenario asserts the wire contract, not host
|
|
27
|
+
* behavior.
|
|
28
|
+
*
|
|
29
|
+
* Spec references:
|
|
30
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-deployment.md
|
|
31
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0082-agent-deployment-lifecycle.md
|
|
32
|
+
* - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (deployment-event-no-content-leak)
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, expect } from 'vitest';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
39
|
+
import addFormats from 'ajv-formats';
|
|
40
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
41
|
+
|
|
42
|
+
/** Server-free assertion-message helper. */
|
|
43
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
44
|
+
|
|
45
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
46
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('agent-deployment-shape: capability advertisement (RFC 0082, server-free)', () => {
|
|
50
|
+
it('the capabilities schema declares agents.deployment with its sub-flags', () => {
|
|
51
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
52
|
+
const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown> }> }>).agents;
|
|
53
|
+
const deployment = agents?.properties?.deployment;
|
|
54
|
+
expect(deployment, why('capabilities.md §agents', 'agents.deployment MUST be declared')).toBeDefined();
|
|
55
|
+
for (const flag of ['supported', 'channels', 'canary', 'rollback', 'states']) {
|
|
56
|
+
expect(
|
|
57
|
+
deployment?.properties?.[flag],
|
|
58
|
+
why('agent-deployment.md §F', `agents.deployment.${flag} MUST be declared`),
|
|
59
|
+
).toBeDefined();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('agent-deployment-shape: deployment record + AgentRef binding (RFC 0082, server-free)', () => {
|
|
65
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
66
|
+
addFormats(ajv);
|
|
67
|
+
const record = ajv.compile(loadSchema('agent-deployment.schema.json'));
|
|
68
|
+
const agentRef = ajv.compile(loadSchema('agent-ref.schema.json'));
|
|
69
|
+
|
|
70
|
+
it('AgentDeployment validates a conforming record and rejects a bad state / out-of-range canary', () => {
|
|
71
|
+
const good = { agentId: 'core.openwop.agents.support-resolver', version: '2.4.0', state: 'active', canaryPercent: 10, channels: ['stable'] };
|
|
72
|
+
expect(record(good), why('RFC 0082 §C', 'a conforming deployment record MUST validate')).toBe(true);
|
|
73
|
+
expect(record({ ...good, state: 'live' }), why('RFC 0082 §C', 'an out-of-enum state MUST be rejected')).toBe(false);
|
|
74
|
+
expect(record({ ...good, canaryPercent: 150 }), why('RFC 0082 §C', 'canaryPercent > 100 MUST be rejected')).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('AgentRef channel XOR version: each alone and neither validate; both is rejected (RFC 0082 §A)', () => {
|
|
78
|
+
expect(agentRef({ agentId: 'core.x.y.z', version: '1.0.0' }), why('RFC 0082 §A', 'version-only AgentRef MUST validate')).toBe(true);
|
|
79
|
+
expect(agentRef({ agentId: 'core.x.y.z', channel: 'stable' }), why('RFC 0082 §A', 'channel-only AgentRef MUST validate')).toBe(true);
|
|
80
|
+
expect(agentRef({ agentId: 'core.x.y.z' }), why('RFC 0082 §A', 'a ref with neither version nor channel MUST validate (host default)')).toBe(true);
|
|
81
|
+
expect(agentRef({ agentId: 'core.x.y.z', version: '1.0.0', channel: 'stable' }), why('RFC 0082 §A', 'a ref with BOTH version and channel MUST be rejected')).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('agent-deployment-shape: deployment.* event payloads (RFC 0082, server-free)', () => {
|
|
86
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
87
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
88
|
+
addFormats(ajv);
|
|
89
|
+
ajv.addSchema(payloads, 'payloads');
|
|
90
|
+
|
|
91
|
+
const promoted = ajv.getSchema('payloads#/$defs/deploymentPromoted');
|
|
92
|
+
const rolledBack = ajv.getSchema('payloads#/$defs/deploymentRolledBack');
|
|
93
|
+
const canary = ajv.getSchema('payloads#/$defs/deploymentCanaryAdjusted');
|
|
94
|
+
const stateChanged = ajv.getSchema('payloads#/$defs/deploymentStateChanged');
|
|
95
|
+
|
|
96
|
+
it('deployment.promoted validates a content-free promotion record and requires toVersion + toState', () => {
|
|
97
|
+
expect(promoted, 'the deploymentPromoted $def MUST exist').toBeTruthy();
|
|
98
|
+
expect(
|
|
99
|
+
promoted!({ agentId: 'core.openwop.agents.support-resolver', toVersion: '2.4.0', toState: 'active', channel: 'stable', canaryPercent: 10, evalRunId: 'run_abc' }),
|
|
100
|
+
why('RFC 0082 §D', 'a conforming deployment.promoted payload MUST validate'),
|
|
101
|
+
).toBe(true);
|
|
102
|
+
expect(promoted!({ agentId: 'a' }), why('RFC 0082 §D', 'deployment.promoted without toVersion/toState MUST be rejected')).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('deployment.rolled-back / canary.adjusted / state.changed validate conforming records', () => {
|
|
106
|
+
expect(rolledBack!({ agentId: 'a', fromVersion: '2.4.0', toVersion: '2.3.1', rollbackPointer: '2.3.1' }), why('RFC 0082 §D', 'a conforming deployment.rolled-back MUST validate')).toBe(true);
|
|
107
|
+
expect(canary!({ agentId: 'a', version: '2.4.0', fromPercent: 10, toPercent: 50 }), why('RFC 0082 §D', 'a conforming deployment.canary.adjusted MUST validate')).toBe(true);
|
|
108
|
+
expect(stateChanged!({ agentId: 'a', version: '2.4.0', fromState: 'active', toState: 'paused' }), why('RFC 0082 §D', 'a conforming deployment.state.changed MUST validate')).toBe(true);
|
|
109
|
+
expect(stateChanged!({ agentId: 'a', version: '2.4.0', fromState: 'active', toState: 'live' }), why('RFC 0082 §D', 'an out-of-enum toState MUST be rejected')).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('deployment.* events are content-free — a manifest body and a prompt are rejected (deployment-event-no-content-leak)', () => {
|
|
113
|
+
expect(
|
|
114
|
+
promoted!({ agentId: 'a', toVersion: '2.4.0', toState: 'active', manifestBody: '{...}' }),
|
|
115
|
+
why('SECURITY invariant deployment-event-no-content-leak', 'a deployment.promoted MUST NOT carry a manifest body'),
|
|
116
|
+
).toBe(false);
|
|
117
|
+
expect(
|
|
118
|
+
stateChanged!({ agentId: 'a', version: '2.4.0', fromState: 'active', toState: 'paused', prompt: 'system: …' }),
|
|
119
|
+
why('SECURITY invariant deployment-event-no-content-leak', 'a deployment.state.changed MUST NOT carry prompt content'),
|
|
120
|
+
).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('agent-deployment-shape: §B recorded-fact pin + enum (RFC 0082, server-free)', () => {
|
|
125
|
+
it('agent.invocation.started carries the additive recorded-fact resolvedAgentVersion / resolvedChannel', () => {
|
|
126
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
127
|
+
const started = ((payloads.$defs as Record<string, { properties?: Record<string, unknown> }>).agentInvocationStarted)?.properties ?? {};
|
|
128
|
+
expect(started.resolvedAgentVersion, why('RFC 0082 §B', 'agent.invocation.started.resolvedAgentVersion MUST be declared (the channel pin)')).toBeDefined();
|
|
129
|
+
expect(started.resolvedChannel, why('RFC 0082 §B', 'agent.invocation.started.resolvedChannel MUST be declared')).toBeDefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('all four deployment event names appear in the RunEventType enum', () => {
|
|
133
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
134
|
+
const enumVals = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
|
|
135
|
+
for (const e of ['deployment.promoted', 'deployment.rolled-back', 'deployment.canary.adjusted', 'deployment.state.changed']) {
|
|
136
|
+
expect(enumVals).toContain(e);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|