@openwop/openwop-conformance 1.23.0 → 1.25.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 +12 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +54 -0
- package/api/openapi.yaml +102 -0
- package/coverage.md +10 -0
- package/dist/lib/profiles.js +16 -7
- package/fixtures/trigger-events/trigger-event-email.json +18 -0
- package/fixtures/trigger-events/trigger-subscription-registration-email.json +6 -0
- package/fixtures.md +13 -0
- package/package.json +1 -1
- package/schemas/README.md +6 -0
- package/schemas/a2a-task-state.schema.json +78 -0
- package/schemas/capabilities.schema.json +103 -1
- package/schemas/export-bundle.schema.json +66 -0
- package/schemas/goal.schema.json +104 -0
- package/schemas/proposal.schema.json +84 -0
- package/schemas/run-event-payloads.schema.json +80 -2
- package/schemas/run-event.schema.json +6 -1
- package/schemas/trigger-event.schema.json +149 -0
- package/schemas/trigger-subscription-registration.schema.json +67 -0
- package/src/lib/profiles.ts +16 -7
- package/src/scenarios/a2a-task-roundtrip.test.ts +136 -0
- package/src/scenarios/export-bundle-portability.test.ts +120 -0
- package/src/scenarios/fixtures-valid.test.ts +38 -0
- package/src/scenarios/goal-standing-continuation.test.ts +139 -0
- package/src/scenarios/proposal-reviewable-learning.test.ts +129 -0
- package/src/scenarios/trigger-ingestion.test.ts +235 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/trigger-event.schema.json",
|
|
4
|
+
"title": "TriggerEvent",
|
|
5
|
+
"description": "RFC 0099 §F.1. The normalized external-event envelope a host hands a started run as `ctx.triggerData` when an externally-originated event (`webhook`/`email`/`form`) is delivered to an `active` TriggerSubscription (RFC 0083). This is an IN-RUN payload only — it never appears on the durable event log; the `trigger.delivery.attempted` event stays content-free (RFC 0083 §C / SECURITY `trigger-ingestion-content-redaction`). A TriggerEvent carries exactly the per-source sub-object matching its `source`. All content is `untrusted` (SECURITY `threat-model-prompt-injection.md`).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["source", "subscriptionId", "deliveryId", "receivedAt"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"source": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"enum": ["webhook", "email", "form"],
|
|
13
|
+
"description": "Which external source originated the event. A TriggerEvent MUST carry exactly the per-source sub-object matching this value and MUST NOT carry the others (RFC 0099 §F.1)."
|
|
14
|
+
},
|
|
15
|
+
"subscriptionId": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "The RFC 0083 §B subscription this event was delivered to."
|
|
19
|
+
},
|
|
20
|
+
"deliveryId": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1,
|
|
23
|
+
"description": "Stable per-delivery id; equals the `causationId` stamped on `run.started` (RFC 0083 §C-3 / RFC 0040)."
|
|
24
|
+
},
|
|
25
|
+
"dedupKey": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "The host-opaque dedup key (RFC 0083 §C-1). Present iff the subscription's `dedupEnabled`. MUST NOT embed inbound body/header content in cleartext (SECURITY `trigger-ingestion-content-redaction`)."
|
|
28
|
+
},
|
|
29
|
+
"receivedAt": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"format": "date-time",
|
|
32
|
+
"description": "When the host received the external event."
|
|
33
|
+
},
|
|
34
|
+
"verified": {
|
|
35
|
+
"type": "boolean",
|
|
36
|
+
"description": "Whether the host verified the source's authenticity (webhook signature / email DMARC / form CSRF+origin) before delivery per the subscription's `verification` policy (RFC 0099 §F.2). A run MUST be able to gate on this."
|
|
37
|
+
},
|
|
38
|
+
"contentTrust": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"enum": ["untrusted"],
|
|
41
|
+
"description": "Always `untrusted`. Inbound external content reaching an LLM node MUST be wrapped per `threat-model-prompt-injection.md` §UNTRUSTED; a TriggerEvent MUST NOT directly advance a HITL approval gate (the `prompt-injection-mcp-no-approval` invariant generalizes)."
|
|
42
|
+
},
|
|
43
|
+
"webhook": { "$ref": "#/$defs/WebhookEvent" },
|
|
44
|
+
"email": { "$ref": "#/$defs/EmailEvent" },
|
|
45
|
+
"form": { "$ref": "#/$defs/FormEvent" }
|
|
46
|
+
},
|
|
47
|
+
"allOf": [
|
|
48
|
+
{
|
|
49
|
+
"description": "RFC 0099 §F.1 — a TriggerEvent MUST carry exactly the per-source sub-object matching its `source` and MUST NOT carry the others.",
|
|
50
|
+
"if": { "properties": { "source": { "const": "webhook" } } },
|
|
51
|
+
"then": { "not": { "anyOf": [{ "required": ["email"] }, { "required": ["form"] }] } }
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"if": { "properties": { "source": { "const": "email" } } },
|
|
55
|
+
"then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["form"] }] } }
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"if": { "properties": { "source": { "const": "form" } } },
|
|
59
|
+
"then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["email"] }] } }
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"$defs": {
|
|
63
|
+
"WebhookEvent": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"additionalProperties": false,
|
|
66
|
+
"description": "Present iff `source == \"webhook\"`.",
|
|
67
|
+
"properties": {
|
|
68
|
+
"method": { "type": "string", "enum": ["POST", "PUT", "PATCH"] },
|
|
69
|
+
"headers": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"additionalProperties": { "type": "string" },
|
|
72
|
+
"description": "Host-allowlisted headers only — a host MUST NOT pass through `Authorization`, `Cookie`, `Proxy-Authorization`, or any header carrying credential material (RFC 0099 §F.1 / SECURITY `trigger-ingestion-content-redaction`)."
|
|
73
|
+
},
|
|
74
|
+
"body": {
|
|
75
|
+
"description": "The parsed JSON body, or a string for non-JSON. Bounded by `triggerBridge.ingestion.maxBodyBytes`."
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"EmailEvent": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"additionalProperties": false,
|
|
82
|
+
"description": "Present iff `source == \"email\"`.",
|
|
83
|
+
"properties": {
|
|
84
|
+
"from": { "type": "string" },
|
|
85
|
+
"to": { "type": "array", "items": { "type": "string" } },
|
|
86
|
+
"subject": { "type": "string" },
|
|
87
|
+
"text": { "type": "string" },
|
|
88
|
+
"html": { "type": "string" },
|
|
89
|
+
"attachments": { "type": "array", "items": { "$ref": "#/$defs/AttachmentRef" } }
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"FormEvent": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"additionalProperties": false,
|
|
95
|
+
"description": "Present iff `source == \"form\"`.",
|
|
96
|
+
"properties": {
|
|
97
|
+
"fields": { "type": "object", "additionalProperties": true, "description": "The submitted field map." },
|
|
98
|
+
"files": { "type": "array", "items": { "$ref": "#/$defs/AttachmentRef" } }
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"AttachmentRef": {
|
|
102
|
+
"type": "object",
|
|
103
|
+
"additionalProperties": false,
|
|
104
|
+
"required": ["ref"],
|
|
105
|
+
"description": "A host-internal opaque handle to a resolved attachment/upload — NEVER a raw external URL the run is expected to fetch itself (RFC 0099 §F.1/§F.4). The host resolves and ingests attachments through its SSRF guard (SECURITY `trigger-ingestion-ssrf`), then hands the run an internal ref.",
|
|
106
|
+
"properties": {
|
|
107
|
+
"ref": {
|
|
108
|
+
"type": "string",
|
|
109
|
+
"minLength": 1,
|
|
110
|
+
"description": "A host-internal opaque handle (e.g. a `host.blobStorage` key)."
|
|
111
|
+
},
|
|
112
|
+
"filename": { "type": "string" },
|
|
113
|
+
"mediaType": { "type": "string" },
|
|
114
|
+
"bytes": { "type": "integer", "minimum": 0 }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"examples": [
|
|
119
|
+
{
|
|
120
|
+
"source": "email",
|
|
121
|
+
"subscriptionId": "sub_7",
|
|
122
|
+
"deliveryId": "dlv_a1b2",
|
|
123
|
+
"dedupKey": "msgid:abc@in.example.com",
|
|
124
|
+
"receivedAt": "2026-06-13T18:04:11Z",
|
|
125
|
+
"verified": true,
|
|
126
|
+
"contentTrust": "untrusted",
|
|
127
|
+
"email": {
|
|
128
|
+
"from": "customer@example.org",
|
|
129
|
+
"to": ["triage-support+sub_7@in.example.com"],
|
|
130
|
+
"subject": "Cannot log in",
|
|
131
|
+
"text": "I keep getting a 403.",
|
|
132
|
+
"attachments": [{ "ref": "blob_a1", "filename": "screenshot.png", "mediaType": "image/png", "bytes": 20481 }]
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"source": "webhook",
|
|
137
|
+
"subscriptionId": "sub_9",
|
|
138
|
+
"deliveryId": "dlv_c3d4",
|
|
139
|
+
"receivedAt": "2026-06-13T18:10:00Z",
|
|
140
|
+
"verified": true,
|
|
141
|
+
"contentTrust": "untrusted",
|
|
142
|
+
"webhook": {
|
|
143
|
+
"method": "POST",
|
|
144
|
+
"headers": { "X-Event-Type": "issue.created" },
|
|
145
|
+
"body": { "issue": { "id": 42, "title": "Bug" } }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/trigger-subscription-registration.schema.json",
|
|
4
|
+
"title": "TriggerSubscriptionRegistration",
|
|
5
|
+
"description": "RFC 0099 §F.2. The portable create request for an external-event trigger subscription, served by `POST /v1/trigger-subscriptions`. Binds an external source (`webhook`/`email`/`form`) to a Workflow to start, with a dedup config and a source-authenticity verification policy. This is the unified create surface RFC 0083 UQ1 left per-source. The response returns the created `TriggerSubscription` (RFC 0083 §B) plus a source-specific `binding`; the binding secret/URL is returned ONCE at creation and is not re-fetchable in cleartext (SR-1).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["source", "workflowId"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"source": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"enum": ["webhook", "email", "form"],
|
|
13
|
+
"description": "The external source this subscription ingests. MUST appear in the host's `capabilities.triggerBridge.ingestion.externalSources[]`."
|
|
14
|
+
},
|
|
15
|
+
"workflowId": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "The Workflow a delivered event starts (the run's `workflowId`). MUST resolve under the caller's RFC 0048 owner triple — a registration MUST NOT bind a workflow the caller cannot start (RFC 0049 scope check)."
|
|
19
|
+
},
|
|
20
|
+
"dedupEnabled": {
|
|
21
|
+
"type": "boolean",
|
|
22
|
+
"default": true,
|
|
23
|
+
"description": "Per RFC 0083 §C-1. Defaults true for external sources — at-least-once inbound delivery without dedup is a footgun."
|
|
24
|
+
},
|
|
25
|
+
"retryPolicy": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"description": "Delivery retry policy (RFC 0083 §C-2). Same shape as `trigger-subscription.schema.json#/properties/retryPolicy`. On exhaustion the delivery transitions to `dead-lettered` (RFC 0053).",
|
|
29
|
+
"properties": {
|
|
30
|
+
"maxAttempts": { "type": "integer", "minimum": 1, "description": "Maximum delivery attempts before dead-lettering." },
|
|
31
|
+
"backoff": { "type": "string", "enum": ["none", "fixed", "exponential"], "description": "Backoff strategy between attempts." }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"verification": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"description": "Source-authenticity policy. The host MUST verify per this policy BEFORE delivery and stamp `TriggerEvent.verified`; an event failing a `required` verification MUST NOT start a run (it transitions `trigger.delivery.attempted{outcome:'dead-lettered'}` with `trigger.subscription.state.changed.reason:'signature-invalid'`).",
|
|
38
|
+
"properties": {
|
|
39
|
+
"mode": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"enum": ["required", "best-effort", "none"],
|
|
42
|
+
"default": "required",
|
|
43
|
+
"description": "`required`: an unverified event is dead-lettered, no run. `best-effort`: the host verifies when it can and stamps `verified` accordingly but still delivers. `none`: no verification."
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"inputMapping": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"additionalProperties": true,
|
|
50
|
+
"description": "OPTIONAL host-extension hint mapping `TriggerEvent` fields onto the workflow's `inputs`. Non-normative shape (host-specific); absent ⇒ the run receives the whole `TriggerEvent` as `ctx.triggerData`."
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"examples": [
|
|
54
|
+
{
|
|
55
|
+
"source": "email",
|
|
56
|
+
"workflowId": "triage-support",
|
|
57
|
+
"dedupEnabled": true,
|
|
58
|
+
"verification": { "mode": "required" }
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"source": "webhook",
|
|
62
|
+
"workflowId": "ingest-jira-issue",
|
|
63
|
+
"verification": { "mode": "required" },
|
|
64
|
+
"retryPolicy": { "maxAttempts": 5, "backoff": "exponential" }
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
package/src/lib/profiles.ts
CHANGED
|
@@ -236,15 +236,18 @@ export function isMemory(c: DiscoveryPayload): boolean {
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
/**
|
|
239
|
-
* `openwop-trigger-bridge` predicate (RFC 0083). Host
|
|
240
|
-
* inbound-work contract: advertises the `triggerBridge`,
|
|
241
|
-
* sink for exhausted deliveries, and has at least one
|
|
242
|
-
* (queue bus, durable webhooks,
|
|
239
|
+
* `openwop-trigger-bridge` predicate (RFC 0083, widened by RFC 0099). Host
|
|
240
|
+
* composes the durable inbound-work contract: advertises the `triggerBridge`,
|
|
241
|
+
* has a `deadLetter` sink for exhausted deliveries, and has at least one
|
|
242
|
+
* durable inbound source (queue bus, durable webhooks, scheduling, OR — per
|
|
243
|
+
* RFC 0099 — externally-ingested `email`/`form` via
|
|
244
|
+
* `triggerBridge.ingestion.externalSources[]`). Capability families are
|
|
243
245
|
* document-root properties (RFC 0073), so this reads `c.triggerBridge` /
|
|
244
|
-
* `c.deadLetter` / `c.queueBus` / `c.webhooks` / `c.scheduling`.
|
|
246
|
+
* `c.deadLetter` / `c.queueBus` / `c.webhooks` / `c.scheduling`. The RFC 0099
|
|
247
|
+
* widening only ADDS disjuncts — a host already in the profile stays in it.
|
|
245
248
|
*
|
|
246
249
|
* @see spec/v1/profiles.md §`openwop-trigger-bridge`
|
|
247
|
-
* @see spec/v1/trigger-bridge.md
|
|
250
|
+
* @see spec/v1/trigger-bridge.md §D / §F
|
|
248
251
|
*/
|
|
249
252
|
export function isTriggerBridge(c: DiscoveryPayload): boolean {
|
|
250
253
|
if (!isCore(c)) return false;
|
|
@@ -253,10 +256,16 @@ export function isTriggerBridge(c: DiscoveryPayload): boolean {
|
|
|
253
256
|
if (!supported(c.triggerBridge)) return false;
|
|
254
257
|
if (!supported(c.deadLetter)) return false;
|
|
255
258
|
const webhooks = c.webhooks as { durable?: unknown } | undefined;
|
|
259
|
+
// RFC 0099: an externally-ingested email/form source is a durable inbound
|
|
260
|
+
// source for the profile (it rides the same four-state machine + dead-letter).
|
|
261
|
+
const ingestion = (c.triggerBridge as { ingestion?: { externalSources?: unknown } } | undefined)?.ingestion;
|
|
262
|
+
const externalSources = Array.isArray(ingestion?.externalSources) ? (ingestion!.externalSources as unknown[]) : [];
|
|
263
|
+
const externalEmailOrForm = externalSources.includes('email') || externalSources.includes('form');
|
|
256
264
|
const durableSource =
|
|
257
265
|
supported(c.queueBus) ||
|
|
258
266
|
supported(c.scheduling) ||
|
|
259
|
-
(webhooks != null && typeof webhooks === 'object' && webhooks.durable === true)
|
|
267
|
+
(webhooks != null && typeof webhooks === 'object' && webhooks.durable === true) ||
|
|
268
|
+
externalEmailOrForm;
|
|
260
269
|
return durableSource;
|
|
261
270
|
}
|
|
262
271
|
|
|
@@ -36,12 +36,20 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import { describe, it, expect } from 'vitest';
|
|
39
|
+
import { readFileSync } from 'node:fs';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
42
|
+
import addFormats from 'ajv-formats';
|
|
39
43
|
import { driver } from '../lib/driver.js';
|
|
40
44
|
import { getA2AFakePeer } from '../lib/a2a-fake-peer.js';
|
|
41
45
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
42
46
|
import { pollUntilTerminal, pollUntilStatus } from '../lib/polling.js';
|
|
47
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
48
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
49
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
43
50
|
|
|
44
51
|
const ROUNDTRIP_FIXTURE = 'conformance-a2a-task-roundtrip';
|
|
52
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
45
53
|
|
|
46
54
|
/** Resolve the A2A endpoint to probe: real-peer env wins; otherwise the in-process fake. */
|
|
47
55
|
function probePeer(): { url: string; isReal: boolean } | null {
|
|
@@ -266,3 +274,131 @@ describe('a2a-task-roundtrip: drift point #4 — REJECTED projects to failed', (
|
|
|
266
274
|
)).toBe(true);
|
|
267
275
|
});
|
|
268
276
|
});
|
|
277
|
+
|
|
278
|
+
// ─── RFC 0100: async / durable A2A tasks ──────────────────────────────
|
|
279
|
+
// Capability-shape always-on + durable-get / resubscribe / push-SSRF gated.
|
|
280
|
+
|
|
281
|
+
describe('a2a-task-roundtrip: A2ATaskState + a2a capability shape (always-on, server-free; RFC 0100)', () => {
|
|
282
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
283
|
+
addFormats(ajv);
|
|
284
|
+
const taskStateSchema = JSON.parse(
|
|
285
|
+
readFileSync(join(SCHEMAS_DIR, 'a2a-task-state.schema.json'), 'utf8'),
|
|
286
|
+
);
|
|
287
|
+
const capabilitiesSchema = JSON.parse(
|
|
288
|
+
readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'),
|
|
289
|
+
);
|
|
290
|
+
const validateTaskState = ajv.compile(taskStateSchema);
|
|
291
|
+
const a2aBlockSchema = capabilitiesSchema.properties?.a2a;
|
|
292
|
+
|
|
293
|
+
it('a conforming A2ATaskState validates with the lowercase-hyphen state enum and taskId == runId', () => {
|
|
294
|
+
const ok = {
|
|
295
|
+
taskId: 'run_x',
|
|
296
|
+
runId: 'run_x',
|
|
297
|
+
contextId: 'ctx_42',
|
|
298
|
+
state: 'input-required',
|
|
299
|
+
interruptKind: 'approval',
|
|
300
|
+
updatedAt: '2026-06-13T19:00:00Z',
|
|
301
|
+
};
|
|
302
|
+
expect(
|
|
303
|
+
validateTaskState(ok),
|
|
304
|
+
`a2a-task-state.schema.json MUST accept a conforming record. Errors: ${JSON.stringify(validateTaskState.errors)}`,
|
|
305
|
+
).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('an UPPERCASE state fails (the persisted/wire form is the A2A v0.3 lowercase-hyphen variant)', () => {
|
|
309
|
+
expect(
|
|
310
|
+
validateTaskState({ taskId: 'r', runId: 'r', state: 'WORKING', updatedAt: '2026-06-13T19:00:00Z' }),
|
|
311
|
+
'a2a-integration.md spelling-drift note — the persisted A2ATaskState.state MUST be the lowercase-hyphen form',
|
|
312
|
+
).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('an A2ATaskState carrying run inputs/artifacts inline fails (additionalProperties:false; SR-1)', () => {
|
|
316
|
+
expect(
|
|
317
|
+
validateTaskState({
|
|
318
|
+
taskId: 'r',
|
|
319
|
+
runId: 'r',
|
|
320
|
+
state: 'completed',
|
|
321
|
+
updatedAt: '2026-06-13T19:00:00Z',
|
|
322
|
+
inputs: { secret: 'x' },
|
|
323
|
+
}),
|
|
324
|
+
'SECURITY a2a-push-egress-ssrf / SR-1 — the persisted record MUST NOT carry run inputs/outputs/artifacts inline',
|
|
325
|
+
).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('a PushConfig requires `url` and structurally rejects a raw (non-truncated) push token', () => {
|
|
329
|
+
const validatePush = ajv.compile({
|
|
330
|
+
$ref: 'https://openwop.dev/spec/v1/a2a-task-state.schema.json#/$defs/PushConfig',
|
|
331
|
+
$defs: taskStateSchema.$defs,
|
|
332
|
+
});
|
|
333
|
+
expect(validatePush({ tokenFingerprint: 'a1b2' }), 'PushConfig MUST require `url`').toBe(false);
|
|
334
|
+
expect(
|
|
335
|
+
validatePush({ url: 'https://caller.example.com/push', tokenFingerprint: 'a'.repeat(33) }),
|
|
336
|
+
'SECURITY a2a-push-egress-ssrf — tokenFingerprint maxLength:32 structurally rejects a full-length raw token (SR-1)',
|
|
337
|
+
).toBe(false);
|
|
338
|
+
expect(
|
|
339
|
+
validatePush({ url: 'https://caller.example.com/push', tokenFingerprint: 'a1b2c3d4' }),
|
|
340
|
+
'a truncated fingerprint + uri url MUST validate',
|
|
341
|
+
).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('the capabilities.a2a block shape is declared (supported + agentCardUrl required; three optional booleans)', () => {
|
|
345
|
+
expect(a2aBlockSchema, 'capabilities.schema.json MUST declare the a2a block').toBeDefined();
|
|
346
|
+
expect(a2aBlockSchema.required).toEqual(expect.arrayContaining(['supported', 'agentCardUrl']));
|
|
347
|
+
expect(a2aBlockSchema.additionalProperties).toBe(false);
|
|
348
|
+
const validateA2A = ajv.compile({ ...a2aBlockSchema, $id: 'urn:test:a2a-block' });
|
|
349
|
+
expect(
|
|
350
|
+
validateA2A({ supported: true, agentCardUrl: 'https://example.com/.well-known/agent-card.json', durableTasks: true }),
|
|
351
|
+
`a conforming a2a block MUST validate. Errors: ${JSON.stringify(validateA2A.errors)}`,
|
|
352
|
+
).toBe(true);
|
|
353
|
+
expect(validateA2A({ supported: true }), 'agentCardUrl is required').toBe(false);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe.skipIf(HTTP_SKIP)('a2a-task-roundtrip: durable tasks/get after disconnect (gated on a2a.durableTasks; RFC 0100)', () => {
|
|
358
|
+
it('a paused-at-HITL run projects a live input-required task on a later tasks/get read', async () => {
|
|
359
|
+
const a2a = await readCapabilityFamily<{ durableTasks?: boolean }>('a2a');
|
|
360
|
+
if (!behaviorGate('a2a.durableTasks', a2a?.durableTasks === true)) return;
|
|
361
|
+
|
|
362
|
+
// Host-extension durable-task read seam (RFC 0100 §2). The host drives a
|
|
363
|
+
// backing run to a paused HITL state; we read the persisted projection
|
|
364
|
+
// WITHOUT holding the original connection.
|
|
365
|
+
const start = await driver.post('/v1/host/sample/a2a/tasks/start', {
|
|
366
|
+
scenario: 'paused-at-approval',
|
|
367
|
+
});
|
|
368
|
+
if (start.status === 404 || start.status === 403) return; // seam unwired — soft-skip
|
|
369
|
+
const taskId = (start.json as { taskId?: string })?.taskId;
|
|
370
|
+
if (!taskId) return;
|
|
371
|
+
|
|
372
|
+
const read = await driver.get(`/v1/host/sample/a2a/tasks/${encodeURIComponent(taskId)}`);
|
|
373
|
+
if (read.status === 404 || read.status === 403) return;
|
|
374
|
+
const state = read.json as { state?: string; runId?: string; metadata?: { openwop?: { interrupt?: { kind?: string } } } };
|
|
375
|
+
expect(
|
|
376
|
+
state.state,
|
|
377
|
+
driver.describe('a2a-integration.md §"Async / durable Tasks"', 'tasks/get after disconnect MUST return the live input-required projection (not a stale working)'),
|
|
378
|
+
).toBe('input-required');
|
|
379
|
+
expect(
|
|
380
|
+
state.runId,
|
|
381
|
+
driver.describe('a2a-task-state.schema.json', 'taskId MUST equal the backing runId'),
|
|
382
|
+
).toBe(taskId);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe.skipIf(HTTP_SKIP)('a2a-task-roundtrip: push-config SSRF (gated on a2a.pushNotifications; RFC 0100)', () => {
|
|
387
|
+
it('registering a pushConfig.url at a private address is refused (a2a-push-egress-ssrf)', async () => {
|
|
388
|
+
const a2a = await readCapabilityFamily<{ pushNotifications?: boolean }>('a2a');
|
|
389
|
+
if (!behaviorGate('a2a.pushNotifications', a2a?.pushNotifications === true)) return;
|
|
390
|
+
|
|
391
|
+
const res = await driver.post('/v1/host/sample/a2a/tasks/push-config', {
|
|
392
|
+
taskId: 'run_x',
|
|
393
|
+
url: 'http://10.0.0.5/push',
|
|
394
|
+
});
|
|
395
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
396
|
+
expect(
|
|
397
|
+
res.status >= 400,
|
|
398
|
+
driver.describe(
|
|
399
|
+
'a2a-integration.md §"Async / durable Tasks"',
|
|
400
|
+
'a2a-push-egress-ssrf — a caller-supplied pushConfig.url at a private/loopback address MUST be refused before any push',
|
|
401
|
+
),
|
|
402
|
+
).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-platform portability — export bundle + tenant import (RFC 0098;
|
|
3
|
+
* `portability.md`). Public test for the protocol-tier SECURITY invariant
|
|
4
|
+
* `export-bundle-no-credential-material`.
|
|
5
|
+
*
|
|
6
|
+
* Two layers:
|
|
7
|
+
*
|
|
8
|
+
* A. Always-on, server-free schema legs — the capability block (incl. the
|
|
9
|
+
* `import ⇒ dryRun` if/then), the `export-bundle.schema.json` shape (no
|
|
10
|
+
* credential-named field admitted), and the content-free `import.applied`
|
|
11
|
+
* event payload.
|
|
12
|
+
*
|
|
13
|
+
* B. Capability-gated behavioral legs — on a host advertising
|
|
14
|
+
* `capabilities.portability` that exposes the `/v1/host/sample/import`
|
|
15
|
+
* seam: a bundle carrying a literal credential value is rejected (422),
|
|
16
|
+
* and a dry-run import makes zero writes. Hosts without the seam soft-skip
|
|
17
|
+
* (404); unadvertised hosts skip via the behavior gate.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/v1/portability.md
|
|
20
|
+
* @see SECURITY/invariants.yaml id: export-bundle-no-credential-material
|
|
21
|
+
* @see RFCS/0098-agent-platform-portability-export-bundle-and-import.md
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
28
|
+
import addFormats from 'ajv-formats';
|
|
29
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
30
|
+
import { driver } from '../lib/driver.js';
|
|
31
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
32
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
33
|
+
|
|
34
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
35
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
36
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CRED_NAMES = ['clientSecret', 'client_secret', 'apiKey', 'api_key', 'accessToken', 'refreshToken', 'password', 'privateKey'] as const;
|
|
40
|
+
|
|
41
|
+
describe('export-bundle-portability: capability advertisement (RFC 0098 §A, server-free)', () => {
|
|
42
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
43
|
+
const portability = (caps.properties as Record<string, { properties?: Record<string, unknown>; if?: unknown; then?: unknown }>).portability;
|
|
44
|
+
|
|
45
|
+
it('capabilities schema declares portability with its sub-flags + the import⇒dryRun if/then', () => {
|
|
46
|
+
expect(portability, why('capabilities.md §portability', 'portability MUST be declared')).toBeDefined();
|
|
47
|
+
for (const flag of ['export', 'import', 'kinds', 'dryRun']) {
|
|
48
|
+
expect(portability?.properties?.[flag], why('RFC 0098 §A', `portability.${flag} MUST be declared`)).toBeDefined();
|
|
49
|
+
}
|
|
50
|
+
expect(portability?.if, why('RFC 0098 §A', 'a JSON-Schema if/then MUST enforce dryRun:true when import:true')).toBeDefined();
|
|
51
|
+
expect(portability?.then, why('RFC 0098 §A', 'the then-branch MUST require dryRun')).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('export-bundle-portability: ExportBundle shape (RFC 0098 §B, server-free)', () => {
|
|
56
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
57
|
+
addFormats(ajv);
|
|
58
|
+
const validate = ajv.compile(loadSchema('export-bundle.schema.json'));
|
|
59
|
+
|
|
60
|
+
const good = {
|
|
61
|
+
bundleVersion: '1',
|
|
62
|
+
source: { origin: 'https://host-a.example', exportedAt: '2026-06-13T00:00:00Z' },
|
|
63
|
+
items: [
|
|
64
|
+
{ kind: 'prompt-template', ref: 'tpl-1', payload: { templateId: 'welcome', version: '1.0.0' } },
|
|
65
|
+
{ kind: 'connection-ref', ref: 'conn-1', dependsOn: ['tpl-1'], payload: { provider: 'slack', credentialRef: 'cred:abc' } },
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
it('validates a conforming bundle with refs only', () => {
|
|
70
|
+
expect(validate(good), why('RFC 0098 §B', `a conforming bundle MUST validate. Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects a wrong bundleVersion, an unknown kind, and a missing item ref', () => {
|
|
74
|
+
expect(validate({ ...good, bundleVersion: '2' }), why('RFC 0098 §B', 'an unsupported bundleVersion MUST be rejected')).toBe(false);
|
|
75
|
+
expect(validate({ ...good, items: [{ kind: 'mystery', ref: 'x', payload: {} }] }), why('RFC 0098 §B', 'an unknown item kind MUST be rejected')).toBe(false);
|
|
76
|
+
expect(validate({ ...good, items: [{ kind: 'agent', payload: {} }] }), why('RFC 0098 §B', 'an item without a ref MUST be rejected')).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('the bundle envelope admits no credential-named field at the root or source level (additionalProperties:false)', () => {
|
|
80
|
+
for (const name of CRED_NAMES) {
|
|
81
|
+
expect(validate({ ...good, [name]: 'xxx' }), why('SECURITY invariant export-bundle-no-credential-material', `a "${name}" field at the bundle root MUST NOT validate`)).toBe(false);
|
|
82
|
+
expect(validate({ ...good, source: { ...good.source, [name]: 'xxx' } }), why('SECURITY invariant export-bundle-no-credential-material', `a "${name}" field under source MUST NOT validate`)).toBe(false);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('export-bundle-portability: content-free event (RFC 0098 §D, server-free)', () => {
|
|
88
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
89
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
90
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
91
|
+
addFormats(ajv);
|
|
92
|
+
ajv.addSchema(payloads, 'payloads');
|
|
93
|
+
|
|
94
|
+
it('import.applied is in the RunEventType enum and is content-free (counts + refs only)', () => {
|
|
95
|
+
const en = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
|
|
96
|
+
expect(en).toContain('import.applied');
|
|
97
|
+
const applied = ajv.getSchema('payloads#/$defs/importApplied')!;
|
|
98
|
+
expect(applied({ bundleOrigin: 'https://host-a.example', counts: { created: 2, skipped: 1 }, secretsToRebind: ['anthropic'] }), why('RFC 0098 §D', 'a content-free import.applied MUST validate')).toBe(true);
|
|
99
|
+
expect(applied({ bundleOrigin: 'h', counts: { created: 1 }, items: [{ payload: {} }] }), why('SECURITY invariant export-bundle-no-credential-material', 'import.applied MUST NOT carry item payloads')).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('export-bundle-portability: behavioral (RFC 0098 §E, capability-gated)', () => {
|
|
104
|
+
it('importing a bundle with a literal credential value is rejected (422)', async () => {
|
|
105
|
+
const portability = await readCapabilityFamily<{ import?: boolean }>('portability');
|
|
106
|
+
if (!behaviorGate('portability', portability !== undefined && portability.import === true)) return;
|
|
107
|
+
|
|
108
|
+
const leaky = {
|
|
109
|
+
bundleVersion: '1',
|
|
110
|
+
source: { origin: 'adapter:conformance' },
|
|
111
|
+
items: [{ kind: 'connection-ref', ref: 'c1', payload: { provider: 'anthropic', apiKey: 'sk-conformance-canary' } }],
|
|
112
|
+
};
|
|
113
|
+
const res = await driver.post('/v1/host/sample/import?dryRun=true', { bundle: leaky });
|
|
114
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
115
|
+
expect(
|
|
116
|
+
res.status,
|
|
117
|
+
driver.describe('portability.md §Invariants clause 1', 'a bundle carrying a literal credential value MUST be rejected (422)'),
|
|
118
|
+
).toBe(422);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -188,6 +188,44 @@ describe('fixtures: connection-pack-manifest schema validity', () => {
|
|
|
188
188
|
}
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
describe('fixtures: trigger-event + registration schema validity', () => {
|
|
192
|
+
// External-event ingestion fixtures live in `fixtures/trigger-events/`
|
|
193
|
+
// (RFC 0099). They are schema-level proof points validated against the
|
|
194
|
+
// `TriggerEvent` / `TriggerSubscriptionRegistration` schemas — NOT seeded
|
|
195
|
+
// into a workflow store. A fixture is dispatched to the right schema by a
|
|
196
|
+
// filename convention: `trigger-event-*` → trigger-event.schema.json;
|
|
197
|
+
// `trigger-subscription-registration-*` → the registration schema.
|
|
198
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
199
|
+
addFormats(ajv);
|
|
200
|
+
// The registration schema $refs the subscription schema; register it.
|
|
201
|
+
ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-subscription.schema.json'), 'utf8')));
|
|
202
|
+
const validateEvent = ajv.compile(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-event.schema.json'), 'utf8')));
|
|
203
|
+
const validateReg = ajv.compile(
|
|
204
|
+
JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-subscription-registration.schema.json'), 'utf8')),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const dir = join(FIXTURES_DIR, 'trigger-events');
|
|
208
|
+
const files = readdirSync(dir)
|
|
209
|
+
.filter((f) => f.endsWith('.json'))
|
|
210
|
+
.sort();
|
|
211
|
+
|
|
212
|
+
it('finds at least one trigger-event fixture (RFC 0099 coverage)', () => {
|
|
213
|
+
expect(files.length).toBeGreaterThan(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
for (const file of files) {
|
|
217
|
+
it(`trigger-events/${file} validates against its schema`, () => {
|
|
218
|
+
const data = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
219
|
+
const validate = file.startsWith('trigger-subscription-registration') ? validateReg : validateEvent;
|
|
220
|
+
const ok = validate(data);
|
|
221
|
+
const errors = (validate.errors ?? [])
|
|
222
|
+
.map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
|
|
223
|
+
.join('\n');
|
|
224
|
+
expect(ok, `Fixture trigger-events/${file} fails its schema:\n${errors}`).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
191
229
|
describe('fixtures: prompt-template schema validity', () => {
|
|
192
230
|
// PromptTemplate fixtures live in `fixtures/prompt-templates/` per
|
|
193
231
|
// RFC 0027 §A. Like pack manifests, they're schema-level proof points,
|