@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,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standing goals — judge-based completion + bounded continuation (RFC 0097;
|
|
3
|
+
* `agent-runtime.md` §"Standing goals"). Public tests for the protocol-tier
|
|
4
|
+
* SECURITY invariants `goal-continuation-bounded` and
|
|
5
|
+
* `goal-completion-judge-only`.
|
|
6
|
+
*
|
|
7
|
+
* Two layers:
|
|
8
|
+
*
|
|
9
|
+
* A. Always-on, server-free schema legs — the capability block, the
|
|
10
|
+
* `goal.schema.json` shape, and the content-free `goal.evaluated` /
|
|
11
|
+
* `goal.closed` event payloads.
|
|
12
|
+
*
|
|
13
|
+
* B. Capability-gated behavioral legs — on a host advertising
|
|
14
|
+
* `capabilities.agents.goals` that exposes the `/v1/host/sample/goals`
|
|
15
|
+
* seam: bounded termination (a never-satisfied goal halts at
|
|
16
|
+
* `maxLoopIterations` with `state: bound-exceeded`) and judge-only
|
|
17
|
+
* completion (a client-supplied `state: satisfied` is refused). Hosts
|
|
18
|
+
* without the seam soft-skip (404); unadvertised hosts skip via the gate.
|
|
19
|
+
*
|
|
20
|
+
* @see spec/v1/agent-runtime.md §"Standing goals"
|
|
21
|
+
* @see SECURITY/invariants.yaml id: goal-continuation-bounded, goal-completion-judge-only
|
|
22
|
+
* @see RFCS/0097-standing-goals-and-judge-based-continuation.md
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
29
|
+
import addFormats from 'ajv-formats';
|
|
30
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
33
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
34
|
+
|
|
35
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
36
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
37
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('goal-standing-continuation: capability advertisement (RFC 0097 §A, server-free)', () => {
|
|
41
|
+
it('capabilities schema declares agents.goals with its required sub-flags', () => {
|
|
42
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
43
|
+
const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown>; required?: string[] }> }>).agents;
|
|
44
|
+
const goals = agents?.properties?.goals;
|
|
45
|
+
expect(goals, why('capabilities.md §agents', 'agents.goals MUST be declared')).toBeDefined();
|
|
46
|
+
for (const flag of ['judge', 'continuation', 'requiresBounds']) {
|
|
47
|
+
expect(goals?.properties?.[flag], why('RFC 0097 §A', `agents.goals.${flag} MUST be declared`)).toBeDefined();
|
|
48
|
+
}
|
|
49
|
+
expect(goals?.required, why('RFC 0097 §A', 'judge + continuation MUST be required')).toEqual(
|
|
50
|
+
expect.arrayContaining(['judge', 'continuation']),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('goal-standing-continuation: Goal shape (RFC 0097 §B, server-free)', () => {
|
|
56
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
57
|
+
addFormats(ajv);
|
|
58
|
+
const validate = ajv.compile(loadSchema('goal.schema.json'));
|
|
59
|
+
|
|
60
|
+
const good = {
|
|
61
|
+
id: 'goal-1',
|
|
62
|
+
objective: 'ship the release checklist',
|
|
63
|
+
state: 'active',
|
|
64
|
+
completion: { check: 'verifier', verifierRef: 'vf-1' },
|
|
65
|
+
continuation: { mode: 'schedule', armRef: 'job-1' },
|
|
66
|
+
bounds: { maxLoopIterations: 7 },
|
|
67
|
+
owner: { tenant: 'acme' },
|
|
68
|
+
createdAt: '2026-06-13T00:00:00Z',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
it('validates a conforming active goal', () => {
|
|
72
|
+
expect(validate(good), why('RFC 0097 §B', `a conforming goal MUST validate. Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects an unknown state, an unknown judge, and a bad continuation mode', () => {
|
|
76
|
+
expect(validate({ ...good, state: 'done' }), why('RFC 0097 §B', 'a state outside the lifecycle enum MUST be rejected')).toBe(false);
|
|
77
|
+
expect(validate({ ...good, completion: { check: 'vibes' } }), why('RFC 0097 §B', 'judge check outside {verifier,host} MUST be rejected')).toBe(false);
|
|
78
|
+
expect(validate({ ...good, continuation: { mode: 'whenever' } }), why('RFC 0097 §B', 'continuation mode outside the enum MUST be rejected')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('goal-standing-continuation: content-free events (RFC 0097 §D, server-free)', () => {
|
|
83
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
84
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
85
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
86
|
+
addFormats(ajv);
|
|
87
|
+
ajv.addSchema(payloads, 'payloads');
|
|
88
|
+
|
|
89
|
+
it('goal.evaluated and goal.closed are in the RunEventType enum', () => {
|
|
90
|
+
const en = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
|
|
91
|
+
expect(en).toContain('goal.evaluated');
|
|
92
|
+
expect(en).toContain('goal.closed');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('goal.evaluated is content-free — an objective text field is rejected; goal.closed requires a terminal finalState', () => {
|
|
96
|
+
const evaluated = ajv.getSchema('payloads#/$defs/goalEvaluated')!;
|
|
97
|
+
expect(evaluated({ goalId: 'g1', satisfied: false, confidence: 0.4, runId: 'r1', iterations: 2 }), why('RFC 0097 §D', 'a content-free goal.evaluated MUST validate')).toBe(true);
|
|
98
|
+
expect(evaluated({ goalId: 'g1', satisfied: false, runId: 'r1', iterations: 2, objective: 'ship it' }), why('RFC 0097 §D', 'goal.evaluated MUST NOT carry objective text')).toBe(false);
|
|
99
|
+
const closed = ajv.getSchema('payloads#/$defs/goalClosed')!;
|
|
100
|
+
expect(closed({ goalId: 'g1', finalState: 'bound-exceeded' }), why('RFC 0097 §D', 'goal.closed with a terminal finalState MUST validate')).toBe(true);
|
|
101
|
+
expect(closed({ goalId: 'g1', finalState: 'active' }), why('RFC 0097 §D', 'goal.closed MUST NOT use the non-terminal `active` state')).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('goal-standing-continuation: behavioral (RFC 0097 §E, capability-gated)', () => {
|
|
106
|
+
it('a goal cannot be created without bounds when requiresBounds is advertised (422)', async () => {
|
|
107
|
+
const agents = await readCapabilityFamily<{ goals?: { requiresBounds?: boolean } }>('agents');
|
|
108
|
+
if (!behaviorGate('agents.goals', agents?.goals !== undefined)) return;
|
|
109
|
+
if (agents?.goals?.requiresBounds === false) return; // host opted out of mandatory bounds
|
|
110
|
+
|
|
111
|
+
const res = await driver.post('/v1/host/sample/goals', {
|
|
112
|
+
objective: 'unbounded work',
|
|
113
|
+
completion: { check: 'host' },
|
|
114
|
+
continuation: { mode: 'manual' },
|
|
115
|
+
});
|
|
116
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
117
|
+
expect(
|
|
118
|
+
res.status,
|
|
119
|
+
driver.describe('agent-runtime.md §"Standing goals" clause 2', 'POST /goals without RFC 0058 bounds MUST be rejected (422) when requiresBounds is advertised'),
|
|
120
|
+
).toBe(422);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('a client MUST NOT set state: satisfied directly', async () => {
|
|
124
|
+
const agents = await readCapabilityFamily<{ goals?: unknown }>('agents');
|
|
125
|
+
if (!behaviorGate('agents.goals', agents?.goals !== undefined)) return;
|
|
126
|
+
|
|
127
|
+
const list = await driver.get('/v1/host/sample/goals?state=active');
|
|
128
|
+
if (list.status === 404 || list.status === 403) return;
|
|
129
|
+
const goals = (list.json as { goals?: Array<{ id: string }> })?.goals ?? [];
|
|
130
|
+
if (goals.length === 0) return;
|
|
131
|
+
|
|
132
|
+
const res = await driver.post(`/v1/host/sample/goals/${goals[0]!.id}`, { state: 'satisfied' });
|
|
133
|
+
if (res.status === 404) return;
|
|
134
|
+
expect(
|
|
135
|
+
res.status >= 400,
|
|
136
|
+
driver.describe('agent-runtime.md §"Standing goals" clause 1', 'a client-supplied state: satisfied MUST be refused — completion is the judge\'s verdict'),
|
|
137
|
+
).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reviewable learning — the proposal lifecycle (RFC 0096; `agent-memory.md`
|
|
3
|
+
* §"Reviewable learning"). Public tests for the protocol-tier SECURITY
|
|
4
|
+
* invariants `proposal-inert-until-applied` and `proposal-no-resynthesis`.
|
|
5
|
+
*
|
|
6
|
+
* Two layers:
|
|
7
|
+
*
|
|
8
|
+
* A. Always-on, server-free schema legs — the capability block, the
|
|
9
|
+
* `proposal.schema.json` shape (incl. the dropped `rule` kind), and the
|
|
10
|
+
* content-free `proposal.created` / `proposal.activated` event payloads.
|
|
11
|
+
*
|
|
12
|
+
* B. Capability-gated behavioral legs — on a host advertising
|
|
13
|
+
* `capabilities.agents.proposals` that exposes the
|
|
14
|
+
* `/v1/host/sample/proposals` seam: inertness (a draft proposal does not
|
|
15
|
+
* influence a run), gated activation (`apply` without scope → 403), and
|
|
16
|
+
* no-re-synthesis (installed artifact byte-matches the last-persisted
|
|
17
|
+
* `artifact`). Hosts without the seam soft-skip (404); unadvertised
|
|
18
|
+
* hosts skip via the behavior gate.
|
|
19
|
+
*
|
|
20
|
+
* @see spec/v1/agent-memory.md §"Reviewable learning"
|
|
21
|
+
* @see SECURITY/invariants.yaml id: proposal-inert-until-applied, proposal-no-resynthesis
|
|
22
|
+
* @see RFCS/0096-reviewable-learning-skill-proposal-lifecycle.md
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
29
|
+
import addFormats from 'ajv-formats';
|
|
30
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
33
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
34
|
+
|
|
35
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
36
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
37
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('proposal-reviewable-learning: capability advertisement (RFC 0096 §A, server-free)', () => {
|
|
41
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
42
|
+
const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown>; required?: string[] }> }>).agents;
|
|
43
|
+
|
|
44
|
+
it('capabilities schema declares agents.proposals with its required sub-flags', () => {
|
|
45
|
+
const proposals = agents?.properties?.proposals;
|
|
46
|
+
expect(proposals, why('capabilities.md §agents', 'agents.proposals MUST be declared')).toBeDefined();
|
|
47
|
+
for (const flag of ['artifactKinds', 'duplicationDetection', 'activation']) {
|
|
48
|
+
expect(proposals?.properties?.[flag], why('RFC 0096 §A', `agents.proposals.${flag} MUST be declared`)).toBeDefined();
|
|
49
|
+
}
|
|
50
|
+
expect(proposals?.required, why('RFC 0096 §A', 'artifactKinds + activation MUST be required')).toEqual(
|
|
51
|
+
expect.arrayContaining(['artifactKinds', 'activation']),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('the `rule` artifact kind is NOT in the enum (dropped — no defining RFC)', () => {
|
|
56
|
+
const kinds = (agents?.properties?.proposals as { properties?: Record<string, { items?: { enum?: string[] } }> })?.properties?.artifactKinds?.items?.enum ?? [];
|
|
57
|
+
expect(kinds, why('RFC 0096 §A', 'artifactKinds enumerates the four kinds with a defining RFC')).toEqual(
|
|
58
|
+
expect.arrayContaining(['agent-pack', 'workflow-chain-pack', 'prompt-template', 'automation']),
|
|
59
|
+
);
|
|
60
|
+
expect(kinds, why('RFC 0096 §A', '`rule` MUST NOT appear — it has no defining RFC, so its artifact is unvalidatable')).not.toContain('rule');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('proposal-reviewable-learning: Proposal shape (RFC 0096 §B, server-free)', () => {
|
|
65
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
66
|
+
addFormats(ajv);
|
|
67
|
+
const validate = ajv.compile(loadSchema('proposal.schema.json'));
|
|
68
|
+
|
|
69
|
+
const good = {
|
|
70
|
+
id: 'prop-1',
|
|
71
|
+
kind: 'workflow-chain-pack',
|
|
72
|
+
state: 'draft',
|
|
73
|
+
artifact: { name: 'weekly-digest', version: '1.0.0' },
|
|
74
|
+
provenance: { sourceRunIds: ['run-a', 'run-b'] },
|
|
75
|
+
owner: { tenant: 'acme' },
|
|
76
|
+
createdAt: '2026-06-13T00:00:00Z',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
it('validates a conforming draft proposal', () => {
|
|
80
|
+
expect(validate(good), why('RFC 0096 §B', `a conforming proposal MUST validate. Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects an unknown state and the dropped `rule` kind', () => {
|
|
84
|
+
expect(validate({ ...good, state: 'live' }), why('RFC 0096 §B', 'a state outside the lifecycle enum MUST be rejected')).toBe(false);
|
|
85
|
+
expect(validate({ ...good, kind: 'rule' }), why('RFC 0096 §B', '`kind: "rule"` MUST be rejected (dropped from the enum)')).toBe(false);
|
|
86
|
+
expect(validate({ ...good, owner: { workspace: 'x' } }), why('RFC 0048', 'owner without tenant MUST be rejected')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('proposal-reviewable-learning: content-free events (RFC 0096 §D, server-free)', () => {
|
|
91
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
92
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
93
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
94
|
+
addFormats(ajv);
|
|
95
|
+
ajv.addSchema(payloads, 'payloads');
|
|
96
|
+
|
|
97
|
+
it('proposal.created and proposal.activated are in the RunEventType enum', () => {
|
|
98
|
+
const en = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
|
|
99
|
+
expect(en).toContain('proposal.created');
|
|
100
|
+
expect(en).toContain('proposal.activated');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('proposal.created is content-free — an artifact body and rationale text are rejected', () => {
|
|
104
|
+
const created = ajv.getSchema('payloads#/$defs/proposalCreated')!;
|
|
105
|
+
expect(created({ proposalId: 'p1', kind: 'agent-pack', sourceRunIds: ['r1'], duplicateOf: null }), why('RFC 0096 §D', 'a content-free proposal.created MUST validate')).toBe(true);
|
|
106
|
+
expect(created({ proposalId: 'p1', kind: 'agent-pack', artifact: { x: 1 } }), why('SECURITY invariant proposal-inert-until-applied', 'proposal.created MUST NOT carry the artifact body')).toBe(false);
|
|
107
|
+
expect(created({ proposalId: 'p1', kind: 'agent-pack', rationale: 'because…' }), why('RFC 0096 §D', 'proposal.created MUST NOT carry rationale text')).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('proposal-reviewable-learning: behavioral (RFC 0096 §E, capability-gated)', () => {
|
|
112
|
+
it('apply without the activation scope is denied (403) and installs nothing', async () => {
|
|
113
|
+
const agents = await readCapabilityFamily<{ proposals?: { activation?: string } }>('agents');
|
|
114
|
+
if (!behaviorGate('agents.proposals', agents?.proposals !== undefined)) return;
|
|
115
|
+
|
|
116
|
+
const list = await driver.get('/v1/host/sample/proposals?state=draft');
|
|
117
|
+
if (list.status === 404 || list.status === 403) return; // seam unwired — soft-skip
|
|
118
|
+
const proposals = (list.json as { proposals?: Array<{ id: string }> })?.proposals ?? [];
|
|
119
|
+
if (proposals.length === 0) return; // nothing to act on — soft-skip
|
|
120
|
+
|
|
121
|
+
// Apply with no auth/scope — MUST be refused.
|
|
122
|
+
const res = await driver.post(`/v1/host/sample/proposals/${proposals[0]!.id}/apply`, {});
|
|
123
|
+
if (res.status === 404) return;
|
|
124
|
+
expect(
|
|
125
|
+
res.status,
|
|
126
|
+
driver.describe('agent-memory.md §"Reviewable learning" clause 2', 'apply without the activation scope MUST be denied (403)'),
|
|
127
|
+
).toBe(403);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External-event trigger ingestion (RFC 0099) — `trigger-bridge.md` §F.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the additive external-event ingestion leg of the RFC 0083
|
|
5
|
+
* durable-trigger bridge: the normalized in-run `TriggerEvent` envelope,
|
|
6
|
+
* the `TriggerSubscriptionRegistration` create contract, and the two new
|
|
7
|
+
* SECURITY invariants `trigger-ingestion-ssrf` +
|
|
8
|
+
* `trigger-ingestion-content-redaction`.
|
|
9
|
+
*
|
|
10
|
+
* Two layers:
|
|
11
|
+
*
|
|
12
|
+
* A. Always-on, server-free schema probes:
|
|
13
|
+
* - `trigger-event.schema.json` round-trips a conforming per-source
|
|
14
|
+
* `TriggerEvent` and enforces the §F.1 one-of rule (a
|
|
15
|
+
* `source:"email"` event carrying a `webhook` sub-object fails).
|
|
16
|
+
* - `AttachmentRef` requires a host-internal `ref` and rejects a raw
|
|
17
|
+
* external `url` field (the public test for `trigger-ingestion-ssrf`
|
|
18
|
+
* at the schema layer — the host never hands the run a fetchable URL).
|
|
19
|
+
* - `contentTrust` is the const `"untrusted"`.
|
|
20
|
+
* - the durable `trigger.delivery.attempted` payload carries ONLY the
|
|
21
|
+
* content-free `{subscriptionId,dedupKey,attempt,outcome}` shape (the
|
|
22
|
+
* public test for `trigger-ingestion-content-redaction` — the inbound
|
|
23
|
+
* body has no slot on the durable event).
|
|
24
|
+
* - `trigger-subscription-registration.schema.json` validates + requires
|
|
25
|
+
* `source` + `workflowId`.
|
|
26
|
+
*
|
|
27
|
+
* B. Capability-gated behavioral leg (`triggerBridge.ingestion`, via the
|
|
28
|
+
* `POST /v1/host/sample/trigger-bridge/ingest` seam): a simulated
|
|
29
|
+
* external event starts a run whose `ctx.triggerData` matches the
|
|
30
|
+
* `TriggerEvent` shape and whose `trigger.delivery.attempted` is
|
|
31
|
+
* content-free; an ingestion-path fetch to a private address is refused
|
|
32
|
+
* (`trigger-ingestion-ssrf`); a `webhook.headers.Authorization`
|
|
33
|
+
* passthrough is stripped (`trigger-ingestion-content-redaction`).
|
|
34
|
+
* Soft-skips when the capability is unadvertised or the seam is unwired.
|
|
35
|
+
*
|
|
36
|
+
* @see spec/v1/trigger-bridge.md §F
|
|
37
|
+
* @see SECURITY/invariants.yaml ids trigger-ingestion-ssrf, trigger-ingestion-content-redaction
|
|
38
|
+
* @see RFCS/0099-external-event-trigger-ingestion.md
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { describe, it, expect } from 'vitest';
|
|
42
|
+
import { readFileSync } from 'node:fs';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
45
|
+
import addFormats from 'ajv-formats';
|
|
46
|
+
import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
|
|
47
|
+
import { driver } from '../lib/driver.js';
|
|
48
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
49
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
50
|
+
|
|
51
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
52
|
+
|
|
53
|
+
const EVENT_SCHEMA_PATH = join(SCHEMAS_DIR, 'trigger-event.schema.json');
|
|
54
|
+
const REG_SCHEMA_PATH = join(SCHEMAS_DIR, 'trigger-subscription-registration.schema.json');
|
|
55
|
+
const EVENT_FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-event-email.json');
|
|
56
|
+
const REG_FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-subscription-registration-email.json');
|
|
57
|
+
|
|
58
|
+
function buildAjv(): Ajv2020 {
|
|
59
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
60
|
+
addFormats(ajv);
|
|
61
|
+
// The registration schema $refs the trigger-subscription schema for
|
|
62
|
+
// `retryPolicy`; register it so the absolute $ref resolves offline.
|
|
63
|
+
for (const name of ['trigger-subscription.schema.json']) {
|
|
64
|
+
ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')));
|
|
65
|
+
}
|
|
66
|
+
return ajv;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('trigger-ingestion: TriggerEvent schema (always-on, server-free)', () => {
|
|
70
|
+
const ajv = buildAjv();
|
|
71
|
+
const validate = ajv.compile(JSON.parse(readFileSync(EVENT_SCHEMA_PATH, 'utf8')));
|
|
72
|
+
|
|
73
|
+
it('the canonical email TriggerEvent fixture validates', () => {
|
|
74
|
+
const ev = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
|
|
75
|
+
expect(
|
|
76
|
+
validate(ev),
|
|
77
|
+
`trigger-event.schema.json MUST accept a conforming email TriggerEvent. Errors: ${JSON.stringify(validate.errors)}`,
|
|
78
|
+
).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('the §F.1 one-of rule holds — a source:"email" event carrying a webhook sub-object fails', () => {
|
|
82
|
+
const ev = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
|
|
83
|
+
ev.webhook = { method: 'POST', body: { x: 1 } };
|
|
84
|
+
expect(
|
|
85
|
+
validate(ev),
|
|
86
|
+
'trigger-bridge.md §F.1 — a TriggerEvent MUST carry exactly the per-source sub-object matching its `source` and MUST NOT carry the others',
|
|
87
|
+
).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('contentTrust MUST be the const "untrusted"', () => {
|
|
91
|
+
const ev = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
|
|
92
|
+
ev.contentTrust = 'trusted';
|
|
93
|
+
expect(
|
|
94
|
+
validate(ev),
|
|
95
|
+
'trigger-bridge.md §F.1 — `contentTrust` MUST be `"untrusted"`',
|
|
96
|
+
).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('AttachmentRef requires a host-internal `ref` and rejects a raw external `url` (trigger-ingestion-ssrf)', () => {
|
|
100
|
+
// Missing ref → invalid.
|
|
101
|
+
const noRef = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
|
|
102
|
+
noRef.email.attachments = [{ filename: 'x.png' }];
|
|
103
|
+
expect(
|
|
104
|
+
validate(noRef),
|
|
105
|
+
'SECURITY invariant trigger-ingestion-ssrf — an AttachmentRef MUST carry a host-internal `ref`',
|
|
106
|
+
).toBe(false);
|
|
107
|
+
|
|
108
|
+
// A raw external `url` the run would fetch itself → invalid
|
|
109
|
+
// (additionalProperties:false structurally forbids it).
|
|
110
|
+
const rawUrl = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
|
|
111
|
+
rawUrl.email.attachments = [{ ref: 'blob_a1', url: 'http://169.254.169.254/latest/meta-data/' }];
|
|
112
|
+
expect(
|
|
113
|
+
validate(rawUrl),
|
|
114
|
+
'SECURITY invariant trigger-ingestion-ssrf — the host MUST NOT hand the run an external URL to fetch itself; AttachmentRef is a host-internal handle only',
|
|
115
|
+
).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('a webhook TriggerEvent with an allowlisted header validates', () => {
|
|
119
|
+
const ev = {
|
|
120
|
+
source: 'webhook',
|
|
121
|
+
subscriptionId: 'sub_9',
|
|
122
|
+
deliveryId: 'dlv_c3d4',
|
|
123
|
+
receivedAt: '2026-06-13T18:10:00Z',
|
|
124
|
+
verified: true,
|
|
125
|
+
contentTrust: 'untrusted',
|
|
126
|
+
webhook: { method: 'POST', headers: { 'X-Event-Type': 'issue.created' }, body: { issue: { id: 42 } } },
|
|
127
|
+
};
|
|
128
|
+
expect(
|
|
129
|
+
validate(ev),
|
|
130
|
+
`a conforming webhook TriggerEvent MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
|
|
131
|
+
).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('trigger-ingestion: content-freeness of the durable trigger.delivery.attempted payload (trigger-ingestion-content-redaction)', () => {
|
|
136
|
+
// The public, schema-layer proof for `trigger-ingestion-content-redaction`:
|
|
137
|
+
// the durable event payload defines ONLY content-free fields. The inbound
|
|
138
|
+
// body/headers/email/form content has NO slot — it lives only in the in-run
|
|
139
|
+
// TriggerEvent (`ctx.triggerData`), never on the event log.
|
|
140
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
141
|
+
addFormats(ajv);
|
|
142
|
+
const payloads = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'run-event-payloads.schema.json'), 'utf8'));
|
|
143
|
+
const deliveryDef = payloads.$defs?.triggerDeliveryAttempted;
|
|
144
|
+
|
|
145
|
+
it('trigger.delivery.attempted declares only the content-free RFC 0083 §C fields', () => {
|
|
146
|
+
expect(deliveryDef, 'run-event-payloads.schema.json MUST define triggerDeliveryAttempted').toBeDefined();
|
|
147
|
+
const props = Object.keys(deliveryDef.properties ?? {});
|
|
148
|
+
// The complete content-free field set — no `body`, `headers`, `email`,
|
|
149
|
+
// `form`, `fields`, or any inbound-content carrier.
|
|
150
|
+
expect(props.sort()).toEqual(['attempt', 'dedupKey', 'outcome', 'runId', 'subscriptionId'].sort());
|
|
151
|
+
for (const banned of ['body', 'headers', 'email', 'form', 'fields', 'html', 'text', 'subject']) {
|
|
152
|
+
expect(
|
|
153
|
+
props.includes(banned),
|
|
154
|
+
`trigger-ingestion-content-redaction — the durable trigger.delivery.attempted payload MUST NOT carry an inbound-content field ("${banned}")`,
|
|
155
|
+
).toBe(false);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('trigger-ingestion: TriggerSubscriptionRegistration schema (always-on, server-free)', () => {
|
|
161
|
+
const ajv = buildAjv();
|
|
162
|
+
const validate = ajv.compile(JSON.parse(readFileSync(REG_SCHEMA_PATH, 'utf8')));
|
|
163
|
+
|
|
164
|
+
it('the canonical registration fixture validates', () => {
|
|
165
|
+
const reg = JSON.parse(readFileSync(REG_FIXTURE, 'utf8'));
|
|
166
|
+
expect(
|
|
167
|
+
validate(reg),
|
|
168
|
+
`trigger-subscription-registration.schema.json MUST accept a conforming registration. Errors: ${JSON.stringify(validate.errors)}`,
|
|
169
|
+
).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('requires source + workflowId and rejects an out-of-enum source', () => {
|
|
173
|
+
expect(validate({ workflowId: 'w' }), 'registration MUST require `source`').toBe(false);
|
|
174
|
+
expect(validate({ source: 'email' }), 'registration MUST require `workflowId`').toBe(false);
|
|
175
|
+
expect(
|
|
176
|
+
validate({ source: 'schedule', workflowId: 'w' }),
|
|
177
|
+
'registration `source` MUST be one of webhook/email/form (schedule is not externally registerable)',
|
|
178
|
+
).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe.skipIf(HTTP_SKIP)('trigger-ingestion: behavioral ingestion + SSRF (capability-gated)', () => {
|
|
183
|
+
it('an ingestion-path fetch to a private address is refused; a delivered event is content-free', async () => {
|
|
184
|
+
const tb = await readCapabilityFamily<{ ingestion?: { externalSources?: string[] } }>('triggerBridge');
|
|
185
|
+
const ingests = Array.isArray(tb?.ingestion?.externalSources) && tb!.ingestion!.externalSources!.length > 0;
|
|
186
|
+
if (!behaviorGate('triggerBridge.ingestion', ingests)) return;
|
|
187
|
+
|
|
188
|
+
// SSRF leg — an attachment whose resolution would hit a private address
|
|
189
|
+
// MUST be refused by the host's safeFetch guard (trigger-ingestion-ssrf).
|
|
190
|
+
const ssrf = await driver.post('/v1/host/sample/trigger-bridge/ingest', {
|
|
191
|
+
source: 'email',
|
|
192
|
+
verification: { mode: 'none' },
|
|
193
|
+
attachmentUrl: 'http://169.254.169.254/latest/meta-data/',
|
|
194
|
+
});
|
|
195
|
+
if (ssrf.status === 404 || ssrf.status === 403) return; // seam unwired — soft-skip
|
|
196
|
+
|
|
197
|
+
const ssrfBody = ssrf.json as { triggerEvent?: { email?: { attachments?: unknown[] } }; ssrfRefused?: boolean } | undefined;
|
|
198
|
+
expect(
|
|
199
|
+
ssrfBody?.ssrfRefused === true ||
|
|
200
|
+
(ssrfBody?.triggerEvent?.email?.attachments ?? []).length === 0,
|
|
201
|
+
driver.describe(
|
|
202
|
+
'trigger-bridge.md §F.4',
|
|
203
|
+
'trigger-ingestion-ssrf — an ingestion-path fetch to a private address MUST be refused (attachment dropped), the run still starting on the rest of the event',
|
|
204
|
+
),
|
|
205
|
+
).toBe(true);
|
|
206
|
+
|
|
207
|
+
// Content-redaction leg — the durable trigger.delivery.attempted MUST be
|
|
208
|
+
// content-free even when the inbound carried a credential-bearing header.
|
|
209
|
+
const del = await driver.post('/v1/host/sample/trigger-bridge/ingest', {
|
|
210
|
+
source: 'webhook',
|
|
211
|
+
verification: { mode: 'none' },
|
|
212
|
+
webhook: { method: 'POST', headers: { Authorization: 'Bearer canary' }, body: { x: 1 } },
|
|
213
|
+
});
|
|
214
|
+
if (del.status === 404 || del.status === 403) return;
|
|
215
|
+
const delBody = del.json as
|
|
216
|
+
| { deliveryEvent?: Record<string, unknown>; triggerEvent?: { webhook?: { headers?: Record<string, string> } } }
|
|
217
|
+
| undefined;
|
|
218
|
+
const evtJson = JSON.stringify(delBody?.deliveryEvent ?? {});
|
|
219
|
+
expect(
|
|
220
|
+
evtJson.includes('Bearer canary') === false && evtJson.includes('Authorization') === false,
|
|
221
|
+
driver.describe(
|
|
222
|
+
'trigger-bridge.md §F.4',
|
|
223
|
+
'trigger-ingestion-content-redaction — the durable trigger.delivery.attempted MUST NOT carry inbound body/header content',
|
|
224
|
+
),
|
|
225
|
+
).toBe(true);
|
|
226
|
+
const passedHeaders = delBody?.triggerEvent?.webhook?.headers ?? {};
|
|
227
|
+
expect(
|
|
228
|
+
Object.keys(passedHeaders).every((k) => k.toLowerCase() !== 'authorization'),
|
|
229
|
+
driver.describe(
|
|
230
|
+
'trigger-bridge.md §F.1',
|
|
231
|
+
'webhook.headers MUST be a host-curated allowlist — Authorization MUST NOT pass through',
|
|
232
|
+
),
|
|
233
|
+
).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
});
|