@openwop/openwop-conformance 1.24.0 → 1.26.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 +26 -0
- package/README.md +2 -2
- package/api/openapi.yaml +102 -0
- package/coverage.md +2 -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 +4 -0
- package/schemas/a2a-task-state.schema.json +78 -0
- package/schemas/capabilities.schema.json +25 -1
- package/schemas/envelopes/ui.a2ui-surface.schema.json +154 -0
- 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/a2ui-surface-degrades.test.ts +54 -0
- package/src/scenarios/a2ui-surface-replay.test.ts +84 -0
- package/src/scenarios/a2ui-surface-shape.test.ts +162 -0
- package/src/scenarios/a2ui-surface-version-refusal.test.ts +77 -0
- package/src/scenarios/a2ui-untrusted-blocks-approval.test.ts +68 -0
- package/src/scenarios/fixtures-valid.test.ts +38 -0
- package/src/scenarios/trigger-ingestion.test.ts +235 -0
|
@@ -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,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2ui-surface-degrades — RFC 0102 §A point: graceful degradation.
|
|
3
|
+
*
|
|
4
|
+
* A consumer that does NOT advertise `ui.a2ui-surface` and receives one
|
|
5
|
+
* MUST fall back to store-without-render and MUST NOT fail the run (N6;
|
|
6
|
+
* `ui.a2ui-surface` is an OPTIONAL advertised kind, not a MUST-recognize
|
|
7
|
+
* universal kind — precedent `artifact-type-store-without-render`).
|
|
8
|
+
*
|
|
9
|
+
* Always-on (server-free): `ui.a2ui-surface` is NOT one of the four
|
|
10
|
+
* universal kinds, so a non-advertising consumer is entitled to degrade.
|
|
11
|
+
* Capability-gated (HTTP): posting the kind to a host that does not list it
|
|
12
|
+
* in `supportedEnvelopes` is gated (run not failed), not accepted.
|
|
13
|
+
*
|
|
14
|
+
* @see RFCS/0102-a2ui-agent-authored-interface-surfaces.md §A
|
|
15
|
+
* @see spec/v1/ai-envelope.md §"A2UI surfaces"
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest';
|
|
19
|
+
import { driver } from '../lib/driver.js';
|
|
20
|
+
|
|
21
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
22
|
+
const UNIVERSAL_KINDS = ['clarification.request', 'schema.request', 'schema.response', 'error'];
|
|
23
|
+
|
|
24
|
+
describe('a2ui-surface-degrades: optional-advertised, not universal (RFC 0102 §A)', () => {
|
|
25
|
+
it('ui.a2ui-surface is NOT a MUST-recognize universal kind', () => {
|
|
26
|
+
expect(
|
|
27
|
+
UNIVERSAL_KINDS.includes('ui.a2ui-surface'),
|
|
28
|
+
'ai-envelope.md §"A2UI surfaces": ui.a2ui-surface MUST be optional/advertised so an unrecognizing consumer may store-without-render',
|
|
29
|
+
).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe.skipIf(HTTP_SKIP)('a2ui-surface-degrades: unadvertised kind is gated, run survives (RFC 0102 §A)', () => {
|
|
34
|
+
it('posting ui.a2ui-surface to a host that does not advertise it → gated (not failed)', async () => {
|
|
35
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', {
|
|
36
|
+
envelope: {
|
|
37
|
+
type: 'ui.a2ui-surface',
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
envelopeId: 'env-a2ui-degrade-1',
|
|
40
|
+
correlationId: 'run-a2ui:node-1:turn-0:deg',
|
|
41
|
+
payload: { catalogVersion: '0.9.1', surface: { components: [] } },
|
|
42
|
+
meta: { source: 'ai-generation', ts: '2026-06-15T10:00:00Z' },
|
|
43
|
+
},
|
|
44
|
+
hostSupportedEnvelopes: ['clarification.request'], // does not advertise ui.a2ui-surface
|
|
45
|
+
});
|
|
46
|
+
if (res.status === 404) return; // seam absent — soft-skip
|
|
47
|
+
expect(res.status).toBe(200);
|
|
48
|
+
const body = res.json as { status?: string };
|
|
49
|
+
expect(
|
|
50
|
+
body.status,
|
|
51
|
+
driver.describe('RFC 0102 §A (N6)', 'an unadvertised ui.a2ui-surface MUST be gated, never crash the run'),
|
|
52
|
+
).toBe('gated');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2ui-surface-replay — RFC 0102 §A.6: replay determinism by correlationId.
|
|
3
|
+
*
|
|
4
|
+
* The surface envelope replays by `correlationId` (ai-envelope.md §"Replay
|
|
5
|
+
* determinism"); on recovery/`:fork` the cached outcome is returned and the
|
|
6
|
+
* surface is NEVER regenerated. Re-emission with the same correlationId but a
|
|
7
|
+
* divergent `type` refuses with `envelope_correlation_conflict`. Durable
|
|
8
|
+
* state is exactly `(surface envelope, submitted resume value)`.
|
|
9
|
+
*
|
|
10
|
+
* Always-on (server-free): the surface payload is self-contained (no external
|
|
11
|
+
* `$ref`), the precondition that makes a stored surface replay deterministically
|
|
12
|
+
* after the external A2UI catalog ships a breaking version.
|
|
13
|
+
* Capability-gated (HTTP): same correlationId + different type → conflict.
|
|
14
|
+
*
|
|
15
|
+
* @see RFCS/0102-a2ui-agent-authored-interface-surfaces.md §A.6
|
|
16
|
+
* @see spec/v1/ai-envelope.md §"Replay determinism"
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
24
|
+
|
|
25
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
26
|
+
const schema = JSON.parse(
|
|
27
|
+
readFileSync(join(SCHEMAS_DIR, 'envelopes/ui.a2ui-surface.schema.json'), 'utf8'),
|
|
28
|
+
) as Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
function refStrings(node: unknown, out: string[]): void {
|
|
31
|
+
if (!node || typeof node !== 'object') return;
|
|
32
|
+
if (Array.isArray(node)) {
|
|
33
|
+
node.forEach((n) => refStrings(n, out));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const obj = node as Record<string, unknown>;
|
|
37
|
+
if (typeof obj['$ref'] === 'string') out.push(obj['$ref'] as string);
|
|
38
|
+
Object.values(obj).forEach((v) => refStrings(v, out));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('a2ui-surface-replay: self-contained surface (RFC 0102 §A.6)', () => {
|
|
42
|
+
it('all $refs are internal (#/...) — surface renders from the payload alone on replay', () => {
|
|
43
|
+
const refs: string[] = [];
|
|
44
|
+
refStrings(schema, refs);
|
|
45
|
+
const external = refs.filter((r) => !r.startsWith('#'));
|
|
46
|
+
expect(
|
|
47
|
+
external,
|
|
48
|
+
'RFC 0102 §A.6: a stored surface MUST be self-contained for deterministic :fork/replay (no live external-catalog ref)',
|
|
49
|
+
).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe.skipIf(HTTP_SKIP)('a2ui-surface-replay: correlationId conflict on type divergence (RFC 0102 §A.6)', () => {
|
|
54
|
+
const base = {
|
|
55
|
+
schemaVersion: 1,
|
|
56
|
+
correlationId: 'run-a2ui:node-1:turn-0:rep',
|
|
57
|
+
payload: { catalogVersion: '0.9.1', surface: { components: [] } },
|
|
58
|
+
meta: { source: 'ai-generation', ts: '2026-06-15T10:00:00Z' },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
it('re-emission with same correlationId + different type → envelope_correlation_conflict', async () => {
|
|
62
|
+
const first = await driver.post('/v1/host/sample/envelope/accept', {
|
|
63
|
+
envelope: { ...base, type: 'ui.a2ui-surface', envelopeId: 'env-a2ui-rep-1' },
|
|
64
|
+
hostSupportedEnvelopes: ['ui.a2ui-surface', 'clarification.request'],
|
|
65
|
+
});
|
|
66
|
+
if (first.status === 404) return; // seam absent — soft-skip
|
|
67
|
+
|
|
68
|
+
const conflict = await driver.post('/v1/host/sample/envelope/accept', {
|
|
69
|
+
envelope: {
|
|
70
|
+
...base,
|
|
71
|
+
type: 'clarification.request',
|
|
72
|
+
envelopeId: 'env-a2ui-rep-2',
|
|
73
|
+
payload: { questions: [{ id: 'q', question: 'x' }] },
|
|
74
|
+
},
|
|
75
|
+
hostSupportedEnvelopes: ['ui.a2ui-surface', 'clarification.request'],
|
|
76
|
+
});
|
|
77
|
+
if (conflict.status === 404) return;
|
|
78
|
+
const body = conflict.json as { status?: string; reason?: string };
|
|
79
|
+
expect(
|
|
80
|
+
body.reason ?? '',
|
|
81
|
+
driver.describe('ai-envelope.md §"Replay determinism"', 'same correlationId + divergent type MUST conflict'),
|
|
82
|
+
).toContain('envelope_correlation_conflict');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2ui-surface-shape — RFC 0102 §A wire-shape conformance (server-free).
|
|
3
|
+
*
|
|
4
|
+
* Asserts (always-on, Ajv2020, no host required):
|
|
5
|
+
* 1. `schemas/envelopes/ui.a2ui-surface.schema.json` compiles under Ajv2020.
|
|
6
|
+
* 2. A positive surface payload (closed catalog components, advertised
|
|
7
|
+
* `catalogVersion`, a confined resume/exchange action) validates.
|
|
8
|
+
* 3. Negatives fail per the closed schema:
|
|
9
|
+
* - missing required `catalogVersion` / `surface`
|
|
10
|
+
* - an out-of-catalog component object (additionalProperties:false)
|
|
11
|
+
* - a component carrying an extra/script-bearing property
|
|
12
|
+
* - a `catalogVersion` the schema does not enumerate
|
|
13
|
+
* - an `action.button` whose `action.target` is outside
|
|
14
|
+
* `enum["resume","exchange"]` (the structural half of
|
|
15
|
+
* `a2ui-action-confinement`), or carries an arbitrary URL field.
|
|
16
|
+
*
|
|
17
|
+
* The closed `anyOf` `surface` shape is the enabling precondition for the
|
|
18
|
+
* render-side invariants `a2ui-surface-no-code-exec` /
|
|
19
|
+
* `a2ui-surface-no-network-egress` (reference-app probes): a surface that
|
|
20
|
+
* smuggles a script-bearing field or an unconfined action target fails
|
|
21
|
+
* validation before it can reach a renderer.
|
|
22
|
+
*
|
|
23
|
+
* @see RFCS/0102-a2ui-agent-authored-interface-surfaces.md §A
|
|
24
|
+
* @see spec/v1/ai-envelope.md §"A2UI surfaces"
|
|
25
|
+
* @see schemas/envelopes/ui.a2ui-surface.schema.json
|
|
26
|
+
* @see SECURITY/invariants.yaml (a2ui-action-confinement)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
34
|
+
|
|
35
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
36
|
+
const schema = JSON.parse(
|
|
37
|
+
readFileSync(join(SCHEMAS_DIR, 'envelopes/ui.a2ui-surface.schema.json'), 'utf8'),
|
|
38
|
+
) as Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
function positivePayload(): Record<string, unknown> {
|
|
41
|
+
return {
|
|
42
|
+
catalogVersion: '0.9.1',
|
|
43
|
+
surface: {
|
|
44
|
+
title: 'Schedule the kickoff',
|
|
45
|
+
components: [
|
|
46
|
+
{ component: 'heading', text: 'Kickoff', level: 2 },
|
|
47
|
+
{ component: 'text', text: 'Pick a date and confirm.' },
|
|
48
|
+
{ component: 'field.text', id: 'name', label: 'Your name', required: true },
|
|
49
|
+
{ component: 'field.date', id: 'when', label: 'Date' },
|
|
50
|
+
{
|
|
51
|
+
component: 'field.select',
|
|
52
|
+
id: 'room',
|
|
53
|
+
label: 'Room',
|
|
54
|
+
options: [
|
|
55
|
+
{ value: 'a', label: 'Room A' },
|
|
56
|
+
{ value: 'b', label: 'Room B' },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{ component: 'field.checkbox', id: 'remind', label: 'Remind me', default: true },
|
|
60
|
+
{ component: 'action.button', id: 'go', label: 'Confirm', action: { target: 'resume' } },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
reasoning: 'Collect the kickoff details.',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('a2ui-surface-shape: schema compile + round-trip (RFC 0102 §A)', () => {
|
|
68
|
+
const validate = ajv.compile(schema);
|
|
69
|
+
|
|
70
|
+
it('ui.a2ui-surface.schema.json compiles under Ajv2020', () => {
|
|
71
|
+
expect(
|
|
72
|
+
validate,
|
|
73
|
+
'RFC 0102 §A: the core ui.a2ui-surface payload schema MUST compile',
|
|
74
|
+
).toBeTypeOf('function');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('accepts a positive surface payload (closed catalog + confined action)', () => {
|
|
78
|
+
const ok = validate(positivePayload());
|
|
79
|
+
expect(
|
|
80
|
+
ok,
|
|
81
|
+
`RFC 0102 §A: positive surface MUST validate; errors: ${JSON.stringify(validate.errors)}`,
|
|
82
|
+
).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('accepts an exchange-targeted action (the other confined target)', () => {
|
|
86
|
+
const p = positivePayload();
|
|
87
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
88
|
+
{ component: 'action.button', id: 'send', label: 'Send', action: { target: 'exchange' } },
|
|
89
|
+
];
|
|
90
|
+
expect(validate(p)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects a payload missing required `catalogVersion`', () => {
|
|
94
|
+
const p = positivePayload();
|
|
95
|
+
delete p.catalogVersion;
|
|
96
|
+
expect(validate(p), 'RFC 0102 §A: `catalogVersion` is REQUIRED').toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('rejects a payload missing required `surface`', () => {
|
|
100
|
+
const p = positivePayload();
|
|
101
|
+
delete p.surface;
|
|
102
|
+
expect(validate(p), 'RFC 0102 §A: `surface` is REQUIRED').toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects a `catalogVersion` the schema does not enumerate (unknown version)', () => {
|
|
106
|
+
const p = positivePayload();
|
|
107
|
+
p.catalogVersion = '9.9.9';
|
|
108
|
+
expect(
|
|
109
|
+
validate(p),
|
|
110
|
+
'RFC 0102 §A.3: an unadvertised catalogVersion MUST fail the enumerated set',
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects an out-of-catalog component object (additionalProperties:false)', () => {
|
|
115
|
+
const p = positivePayload();
|
|
116
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
117
|
+
{ component: 'iframe', src: 'https://evil.example/x' } as Record<string, unknown>,
|
|
118
|
+
];
|
|
119
|
+
expect(
|
|
120
|
+
validate(p),
|
|
121
|
+
'RFC 0102 §A.1: an out-of-catalog component MUST fail the closed anyOf',
|
|
122
|
+
).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects a known component carrying an extra (script-bearing) property', () => {
|
|
126
|
+
const p = positivePayload();
|
|
127
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
128
|
+
{ component: 'heading', text: 'Hi', onClick: "fetch('https://evil')" } as Record<string, unknown>,
|
|
129
|
+
];
|
|
130
|
+
expect(
|
|
131
|
+
validate(p),
|
|
132
|
+
'RFC 0102 §A.1: additionalProperties:false MUST reject a smuggled code field',
|
|
133
|
+
).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('rejects an action whose target is outside enum["resume","exchange"] (action confinement)', () => {
|
|
137
|
+
const p = positivePayload();
|
|
138
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
139
|
+
{ component: 'action.button', id: 'x', label: 'X', action: { target: 'http://evil.example' } },
|
|
140
|
+
];
|
|
141
|
+
expect(
|
|
142
|
+
validate(p),
|
|
143
|
+
'RFC 0102 §A.4 / SECURITY a2ui-action-confinement: an action target outside resume/exchange MUST be rejected',
|
|
144
|
+
).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects an action carrying an arbitrary URL field (no unconfined egress target)', () => {
|
|
148
|
+
const p = positivePayload();
|
|
149
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
150
|
+
{
|
|
151
|
+
component: 'action.button',
|
|
152
|
+
id: 'x',
|
|
153
|
+
label: 'X',
|
|
154
|
+
action: { target: 'resume', url: 'https://evil.example/exfil' } as Record<string, unknown>,
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
expect(
|
|
158
|
+
validate(p),
|
|
159
|
+
'RFC 0102 §A.4: action additionalProperties:false MUST reject an arbitrary URL target',
|
|
160
|
+
).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2ui-surface-version-refusal — RFC 0102 §A.3 (C3): catalog version is a
|
|
3
|
+
* host-enumerated set, not a free string. A `catalogVersion` the host does
|
|
4
|
+
* not advertise MUST be refused with `unknown_schema_version`
|
|
5
|
+
* (ai-envelope.md §"Schema version advertisement"), and the stored surface
|
|
6
|
+
* MUST be self-contained for deterministic `:fork`/replay.
|
|
7
|
+
*
|
|
8
|
+
* Always-on (server-free): the core schema enumerates `catalogVersion`
|
|
9
|
+
* (closed set), so a non-advertised version fails validation; the schema
|
|
10
|
+
* carries no external `$ref` (surface is self-contained).
|
|
11
|
+
* Capability-gated (HTTP): a live host refuses an unadvertised version.
|
|
12
|
+
*
|
|
13
|
+
* @see RFCS/0102-a2ui-agent-authored-interface-surfaces.md §A.3
|
|
14
|
+
* @see spec/v1/ai-envelope.md §"A2UI surfaces", §"Schema version advertisement"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { driver } from '../lib/driver.js';
|
|
22
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
23
|
+
|
|
24
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
25
|
+
const schema = JSON.parse(
|
|
26
|
+
readFileSync(join(SCHEMAS_DIR, 'envelopes/ui.a2ui-surface.schema.json'), 'utf8'),
|
|
27
|
+
) as Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
function hasAbsoluteRef(node: unknown): boolean {
|
|
30
|
+
if (!node || typeof node !== 'object') return false;
|
|
31
|
+
if (Array.isArray(node)) return node.some(hasAbsoluteRef);
|
|
32
|
+
const obj = node as Record<string, unknown>;
|
|
33
|
+
if (typeof obj['$ref'] === 'string' && !(obj['$ref'] as string).startsWith('#')) return true;
|
|
34
|
+
return Object.values(obj).some(hasAbsoluteRef);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('a2ui-surface-version-refusal: enumerated catalogVersion (RFC 0102 §A.3)', () => {
|
|
38
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
39
|
+
const validate = ajv.compile(schema);
|
|
40
|
+
|
|
41
|
+
it('catalogVersion is a closed enum — an unadvertised version fails validation', () => {
|
|
42
|
+
const ok = validate({ catalogVersion: '9.9.9', surface: { components: [] } });
|
|
43
|
+
expect(
|
|
44
|
+
ok,
|
|
45
|
+
'RFC 0102 §A.3: a catalogVersion outside the host-enumerated set MUST fail (→ unknown_schema_version at runtime)',
|
|
46
|
+
).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('surface payload is self-contained — no external $ref (replay determinism)', () => {
|
|
50
|
+
expect(
|
|
51
|
+
hasAbsoluteRef(schema),
|
|
52
|
+
'RFC 0102 §A.3: the surface MUST be self-contained, never a live external-catalog reference',
|
|
53
|
+
).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe.skipIf(HTTP_SKIP)('a2ui-surface-version-refusal: live host refuses unadvertised version (RFC 0102 §A.3)', () => {
|
|
58
|
+
it('ui.a2ui-surface with an unadvertised catalogVersion → refused', async () => {
|
|
59
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', {
|
|
60
|
+
envelope: {
|
|
61
|
+
type: 'ui.a2ui-surface',
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
envelopeId: 'env-a2ui-ver-1',
|
|
64
|
+
correlationId: 'run-a2ui:node-1:turn-0:ver',
|
|
65
|
+
payload: { catalogVersion: '9.9.9', surface: { components: [] } },
|
|
66
|
+
meta: { source: 'ai-generation', ts: '2026-06-15T10:00:00Z' },
|
|
67
|
+
},
|
|
68
|
+
hostSupportedEnvelopes: ['ui.a2ui-surface'],
|
|
69
|
+
});
|
|
70
|
+
if (res.status === 404) return; // seam absent — soft-skip
|
|
71
|
+
const body = res.json as { status?: string; reason?: string };
|
|
72
|
+
expect(
|
|
73
|
+
body.status === 'invalid' || body.status === 'refused',
|
|
74
|
+
driver.describe('RFC 0102 §A.3', 'an unadvertised catalogVersion MUST be refused'),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2ui-untrusted-blocks-approval — RFC 0102 §A.5: an untrusted-authored
|
|
3
|
+
* surface MUST NOT advance an approval gate.
|
|
4
|
+
*
|
|
5
|
+
* A `ui.a2ui-surface` emitted by a node that consumed untrusted MCP/A2A
|
|
6
|
+
* content carries `meta.contentTrust: 'untrusted'`; the existing
|
|
7
|
+
* `untrusted_content_blocks_approval` rule then blocks that surface from
|
|
8
|
+
* advancing an `approval` interrupt. This is a composition of the existing
|
|
9
|
+
* untrusted-content rule for the new kind, not a new taint primitive.
|
|
10
|
+
*
|
|
11
|
+
* Always-on (server-free): `ui.a2ui-surface` is an advertised (non-universal)
|
|
12
|
+
* kind, so the envelope trust-boundary machinery (`meta.contentTrust`) applies
|
|
13
|
+
* to it exactly as to any other envelope — the precondition for the block.
|
|
14
|
+
* Capability-gated (HTTP): an untrusted surface bound to an approval interrupt
|
|
15
|
+
* is refused.
|
|
16
|
+
*
|
|
17
|
+
* @see RFCS/0102-a2ui-agent-authored-interface-surfaces.md §A.5
|
|
18
|
+
* @see spec/v1/ai-envelope.md §"A2UI surfaces", §"Trust boundary"
|
|
19
|
+
* @see SECURITY/invariants.yaml (a2ui-untrusted-blocks-approval, untrusted_content_blocks_approval)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { driver } from '../lib/driver.js';
|
|
24
|
+
|
|
25
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
26
|
+
const UNIVERSAL_KINDS = ['clarification.request', 'schema.request', 'schema.response', 'error'];
|
|
27
|
+
|
|
28
|
+
describe('a2ui-untrusted-blocks-approval: trust machinery applies (RFC 0102 §A.5)', () => {
|
|
29
|
+
it('ui.a2ui-surface is an advertised kind subject to meta.contentTrust gating (not universal/always-allowed)', () => {
|
|
30
|
+
expect(
|
|
31
|
+
UNIVERSAL_KINDS.includes('ui.a2ui-surface'),
|
|
32
|
+
'ai-envelope.md §"A2UI surfaces" / §"Trust boundary": a ui.a2ui-surface is trust-gated like any advertised envelope, so an untrusted surface is subject to untrusted_content_blocks_approval',
|
|
33
|
+
).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe.skipIf(HTTP_SKIP)('a2ui-untrusted-blocks-approval: untrusted surface cannot drive an approval (RFC 0102 §A.5)', () => {
|
|
38
|
+
it('an untrusted-marked ui.a2ui-surface bound to an approval interrupt is refused', async () => {
|
|
39
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', {
|
|
40
|
+
envelope: {
|
|
41
|
+
type: 'ui.a2ui-surface',
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
envelopeId: 'env-a2ui-untrusted-1',
|
|
44
|
+
correlationId: 'run-a2ui:node-1:turn-0:unt',
|
|
45
|
+
payload: {
|
|
46
|
+
catalogVersion: '0.9.1',
|
|
47
|
+
surface: {
|
|
48
|
+
components: [
|
|
49
|
+
{ component: 'action.button', id: 'approve', label: 'Approve', action: { target: 'resume' } },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
meta: { source: 'ai-generation', ts: '2026-06-15T10:00:00Z', contentTrust: 'untrusted' },
|
|
54
|
+
},
|
|
55
|
+
hostSupportedEnvelopes: ['ui.a2ui-surface'],
|
|
56
|
+
boundInterruptKind: 'approval',
|
|
57
|
+
});
|
|
58
|
+
if (res.status === 404) return; // seam absent — soft-skip
|
|
59
|
+
const body = res.json as { status?: string; reason?: string };
|
|
60
|
+
expect(
|
|
61
|
+
body.status === 'blocked' || (body.reason ?? '').includes('untrusted'),
|
|
62
|
+
driver.describe(
|
|
63
|
+
'RFC 0102 §A.5',
|
|
64
|
+
'an untrusted ui.a2ui-surface MUST NOT advance an approval interrupt (untrusted_content_blocks_approval)',
|
|
65
|
+
),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -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,
|