@openwop/openwop-conformance 1.10.0 → 1.12.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 +48 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +70 -0
- package/api/openapi.yaml +268 -1
- package/coverage.md +33 -2
- package/fixtures/oauth-providers/synthetic.json +38 -0
- package/fixtures.md +10 -0
- package/package.json +1 -1
- package/schemas/README.md +12 -0
- package/schemas/agent-deployment-transition.schema.json +49 -0
- package/schemas/agent-deployment.schema.json +54 -0
- package/schemas/agent-eval-suite.schema.json +140 -0
- package/schemas/agent-inventory-response.schema.json +25 -0
- package/schemas/agent-manifest.schema.json +5 -0
- package/schemas/agent-org-chart.schema.json +82 -0
- package/schemas/agent-ref.schema.json +12 -2
- package/schemas/agent-roster-entry.schema.json +81 -0
- package/schemas/agent-roster-response.schema.json +21 -0
- package/schemas/budget-policy.schema.json +18 -0
- package/schemas/capabilities.schema.json +277 -0
- package/schemas/credential-provenance.schema.json +18 -0
- package/schemas/eval-summary.schema.json +92 -0
- package/schemas/node-pack-manifest.schema.json +17 -0
- package/schemas/org-chart-responsibility-view.schema.json +26 -0
- package/schemas/run-event-payloads.schema.json +286 -3
- package/schemas/run-event.schema.json +19 -0
- package/schemas/tool-descriptor.schema.json +63 -0
- package/schemas/trigger-subscription.schema.json +26 -0
- package/src/lib/agentOrgChart.ts +82 -0
- package/src/lib/agentRoster.ts +76 -0
- package/src/lib/liveRuntime.ts +59 -0
- package/src/lib/profiles.ts +157 -0
- package/src/lib/runtimeRequires.ts +38 -0
- package/src/lib/safeFetch.ts +87 -0
- package/src/lib/triggerBridge.ts +74 -0
- package/src/scenarios/agent-deployment-shape.test.ts +139 -0
- package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
- package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
- package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
- package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
- package/src/scenarios/agent-live-structured-output.test.ts +58 -0
- package/src/scenarios/agent-org-chart-scoping.test.ts +137 -0
- package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
- package/src/scenarios/agent-platform-profile.test.ts +158 -0
- package/src/scenarios/agent-roster-attribution.test.ts +179 -0
- package/src/scenarios/agent-roster-shape.test.ts +146 -0
- package/src/scenarios/budget-policy-shape.test.ts +136 -0
- package/src/scenarios/egress-provenance-shape.test.ts +137 -0
- package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
- package/src/scenarios/org-position-no-authority-escalation.test.ts +78 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
- package/src/scenarios/runtime-requires-shape.test.ts +134 -0
- package/src/scenarios/safefetch-behavior.test.ts +99 -0
- package/src/scenarios/safefetch-live-audit.test.ts +175 -0
- package/src/scenarios/spec-corpus-validity.test.ts +19 -3
- package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +126 -0
- package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
- package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-run safe-fetch audit emission — `host-capabilities.md` §host.http
|
|
3
|
+
* (`ctx.http.safeFetch`) + RFC 0076 §B + RFC 0064 §B.
|
|
4
|
+
*
|
|
5
|
+
* Closes the seam-vs-production gap left by `safefetch-behavior.test.ts`. That
|
|
6
|
+
* scenario drives `POST /v1/host/sample/http/safe-fetch` and reads the audit
|
|
7
|
+
* pair the SEAM returns INLINE — it never proves the *production* per-ctx
|
|
8
|
+
* `ctx.http.safeFetch` (the client injected into a real run) emits anything. A
|
|
9
|
+
* host can co-advertise `toolHooks.prePostEvents` + `httpClient.safeFetch`,
|
|
10
|
+
* pass the seam, and still ship a production `createSafeFetch()` with no audit
|
|
11
|
+
* hooks — the "quiet bypass" §host.http line "centralizing egress in the host
|
|
12
|
+
* must increase auditability, not become a quiet bypass" forbids.
|
|
13
|
+
*
|
|
14
|
+
* The normative MUST (host-capabilities.md §host.http; RFC 0076 §B):
|
|
15
|
+
* When `toolHooks.prePostEvents: true` AND `httpClient.safeFetch.supported:
|
|
16
|
+
* true` are BOTH advertised, the host MUST emit the `agent.toolCalled` /
|
|
17
|
+
* `agent.toolReturned` pair (`transport: "http"`) **for every `safeFetch`
|
|
18
|
+
* invocation** — including a *refused* one (a blocked egress attempt is
|
|
19
|
+
* exactly the security-relevant event the audit log must capture).
|
|
20
|
+
*
|
|
21
|
+
* This scenario verifies that MUST against the DURABLE run event log, not the
|
|
22
|
+
* seam's inline echo, and does so **without depending on outbound egress** so
|
|
23
|
+
* the bar can never pass vacuously:
|
|
24
|
+
* 1. EGRESS-FREE FLOOR (required): drive one `ctx.http.safeFetch` to a
|
|
25
|
+
* guaranteed-blocked link-local / cloud-metadata URL inside a REAL run via
|
|
26
|
+
* `POST /v1/host/sample/http/safe-fetch-run`. A conformant SSRF guard
|
|
27
|
+
* refuses it on every host with zero connectivity, yet the production
|
|
28
|
+
* injection + auditHooks path is still exercised, so the durable pair MUST
|
|
29
|
+
* be present. This removes the "no public egress ⇒ green-but-proves-nothing"
|
|
30
|
+
* hole that a `fetched`-only assertion left.
|
|
31
|
+
* 2. SUCCESS-PATH COVERAGE (best-effort): drive a public URL; when it actually
|
|
32
|
+
* `fetched`, assert the same durable pair (catches a host that audits only
|
|
33
|
+
* the reject path). Skipped — not failed — where the environment has no
|
|
34
|
+
* public egress; the floor already proved emission.
|
|
35
|
+
* 3. Read each run's persisted events via the test event-log seam
|
|
36
|
+
* (`GET /v1/host/sample/test/runs/:runId/events`) and assert a `callId`-
|
|
37
|
+
* paired `agent.toolCalled` (`transport:"http"`) / `agent.toolReturned`.
|
|
38
|
+
*
|
|
39
|
+
* Gating: `behaviorGate('openwop-safefetch-live-audit', <both flags>)` — NOT an
|
|
40
|
+
* inline soft-skip. So it skips-with-reason in default mode but FAILS under
|
|
41
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` when a host advertises both flags yet does not
|
|
42
|
+
* emit. This is the RFC 0076 §B → Accepted bar a non-steward host validates
|
|
43
|
+
* against. The run seam itself (`safe-fetch-run`) is host-pending: a 404 from a
|
|
44
|
+
* not-yet-wired seam soft-skips even in strict mode (the seam is test-only
|
|
45
|
+
* infrastructure, distinct from the advertised production capability).
|
|
46
|
+
* The SSRF guarantee reuses the existing `http-client-ssrf-guard` invariant —
|
|
47
|
+
* no new SECURITY invariant; the audit MUST is RFC 0064's existing posture.
|
|
48
|
+
*
|
|
49
|
+
* @see spec/v1/host-capabilities.md §host.http
|
|
50
|
+
* @see spec/v1/host-sample-test-seams.md §"Open seams" (safe-fetch-run)
|
|
51
|
+
* @see RFCS/0076-pack-runtime-requirements-and-host-safe-fetch.md §B
|
|
52
|
+
* @see RFCS/0064-tool-invocation-hooks-and-authorization.md §B
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { describe, it, expect } from 'vitest';
|
|
56
|
+
import { driver } from '../lib/driver.js';
|
|
57
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
58
|
+
import { isSafeFetchLiveAuditAdvertised, safeFetchViaRun } from '../lib/safeFetch.js';
|
|
59
|
+
import { queryTestEvents } from '../lib/event-log-query.js';
|
|
60
|
+
|
|
61
|
+
const PROFILE = 'openwop-safefetch-live-audit';
|
|
62
|
+
const CITE = 'host-capabilities.md §host.http';
|
|
63
|
+
|
|
64
|
+
// A link-local / cloud-metadata URL the SSRF guard MUST refuse — reachable on
|
|
65
|
+
// EVERY host regardless of outbound egress, so the durable-pair assertion never
|
|
66
|
+
// passes vacuously. Per §host.http the audit MUST is per-invocation: a *blocked*
|
|
67
|
+
// safeFetch still emits the agent.toolCalled/agent.toolReturned pair (the
|
|
68
|
+
// toolReturned carries the forbidden status). cf. `http-client-ssrf-guard`.
|
|
69
|
+
const BLOCKED_URL = 'http://169.254.169.254/latest/meta-data/';
|
|
70
|
+
// A public URL the guard SHOULD allow — best-effort coverage of the *success*
|
|
71
|
+
// path; skipped (not failed) where the environment has no public egress.
|
|
72
|
+
const FETCH_URL = 'https://example.com/';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the durable run event log for `runId` and assert a `callId`-paired
|
|
76
|
+
* `agent.toolCalled` (`transport:"http"`) / `agent.toolReturned` exists, with
|
|
77
|
+
* the RFC 0002 §B causation chain tolerated when the host surfaces it. Returns
|
|
78
|
+
* `false` (caller treats as host-pending soft-skip) only when the event-log
|
|
79
|
+
* query seam is unavailable; otherwise asserts and returns `true`.
|
|
80
|
+
*/
|
|
81
|
+
async function assertDurableHttpPair(runId: string, label: string): Promise<boolean> {
|
|
82
|
+
const calledQ = await queryTestEvents(runId, { type: 'agent.toolCalled' });
|
|
83
|
+
const returnedQ = await queryTestEvents(runId, { type: 'agent.toolReturned' });
|
|
84
|
+
if (!calledQ.ok || !returnedQ.ok) {
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
console.warn(`[${PROFILE}] event-log query seam unavailable; host-pending — skipping`);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The HTTP-transport tool call: a durable agent.toolCalled with transport:"http".
|
|
91
|
+
const httpCall = calledQ.events.find((e) => (e.payload as { transport?: string }).transport === 'http');
|
|
92
|
+
expect(
|
|
93
|
+
httpCall !== undefined,
|
|
94
|
+
driver.describe(
|
|
95
|
+
CITE,
|
|
96
|
+
`(${label}) when toolHooks.prePostEvents + safeFetch are both advertised, a production ctx.http.safeFetch call MUST persist an agent.toolCalled with transport:"http" to the durable run event log (not just the seam echo), for EVERY invocation incl. blocked ones`,
|
|
97
|
+
),
|
|
98
|
+
).toBe(true);
|
|
99
|
+
if (!httpCall) return true;
|
|
100
|
+
|
|
101
|
+
const callId = (httpCall.payload as { callId?: string }).callId;
|
|
102
|
+
expect(
|
|
103
|
+
typeof callId === 'string' && callId.length > 0,
|
|
104
|
+
driver.describe(CITE, `(${label}) the persisted agent.toolCalled MUST carry the required callId (run-event-payloads.schema.json §agentToolCalled)`),
|
|
105
|
+
).toBe(true);
|
|
106
|
+
|
|
107
|
+
// The paired agent.toolReturned — matched by the required callId (RFC 0002 §B pairing).
|
|
108
|
+
const paired = returnedQ.events.find((e) => (e.payload as { callId?: string }).callId === callId);
|
|
109
|
+
expect(
|
|
110
|
+
paired !== undefined,
|
|
111
|
+
driver.describe(CITE, `(${label}) the agent.toolCalled MUST be followed by a callId-paired agent.toolReturned in the durable log (no quiet bypass)`),
|
|
112
|
+
).toBe(true);
|
|
113
|
+
|
|
114
|
+
// Stricter, when the host surfaces causation: RFC 0002 §B says
|
|
115
|
+
// toolReturned.causationId === the paired toolCalled.eventId. Tolerate
|
|
116
|
+
// hosts that omit causationId (callId pairing already proven above).
|
|
117
|
+
if (paired && typeof paired.causationId === 'string') {
|
|
118
|
+
expect(
|
|
119
|
+
paired.causationId,
|
|
120
|
+
driver.describe('RFC 0002 §B', 'agent.toolReturned.causationId MUST equal the paired agent.toolCalled.eventId when surfaced'),
|
|
121
|
+
).toBe(httpCall.eventId);
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('safefetch-live-audit (RFC 0076 §B / RFC 0064 §B — production path, durable log)', () => {
|
|
127
|
+
it('a BLOCKED real-run safeFetch emits the durable agent.toolCalled/agent.toolReturned pair (transport:"http") — egress-free floor', async () => {
|
|
128
|
+
const advertised = await isSafeFetchLiveAuditAdvertised();
|
|
129
|
+
if (!behaviorGate(PROFILE, advertised)) return; // default-skip; strict-fail when both flags advertised
|
|
130
|
+
|
|
131
|
+
// Run seam is host-pending infrastructure — soft-skip (even in strict mode)
|
|
132
|
+
// until a safeFetch host wires it. behaviorGate above already enforced the
|
|
133
|
+
// capability co-advertisement; this only gates on the test vehicle.
|
|
134
|
+
const run = await safeFetchViaRun({ url: BLOCKED_URL });
|
|
135
|
+
if (run === null) {
|
|
136
|
+
// eslint-disable-next-line no-console
|
|
137
|
+
console.warn(`[${PROFILE}] safe-fetch-run seam unwired (404); host-pending — skipping`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The metadata IP MUST be refused by a conformant SSRF guard
|
|
142
|
+
// (http-client-ssrf.test.ts owns that contract). Regardless of the exact
|
|
143
|
+
// outcome, the production injection path ran, so the durable audit pair MUST
|
|
144
|
+
// exist — this is the egress-independent floor that makes the bar non-vacuous.
|
|
145
|
+
expect(
|
|
146
|
+
typeof run.runId === 'string' && (run.runId as string).length > 0,
|
|
147
|
+
driver.describe(CITE, 'the safe-fetch-run seam MUST return the runId of the real run it executed the safeFetch in'),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
await assertDurableHttpPair(run.runId as string, 'blocked');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('a FETCHED real-run safeFetch also emits the durable pair (success-path coverage — skipped without public egress)', async () => {
|
|
153
|
+
const advertised = await isSafeFetchLiveAuditAdvertised();
|
|
154
|
+
if (!behaviorGate(PROFILE, advertised)) return;
|
|
155
|
+
|
|
156
|
+
const run = await safeFetchViaRun({ url: FETCH_URL });
|
|
157
|
+
if (run === null) return; // seam unwired — already warned by the floor test
|
|
158
|
+
|
|
159
|
+
if (run.outcome !== 'fetched') {
|
|
160
|
+
// No public egress in this environment — the blocked-path floor already
|
|
161
|
+
// proved the production audit path emits. Skip success-path coverage
|
|
162
|
+
// rather than fail; this is coverage, not the floor.
|
|
163
|
+
// eslint-disable-next-line no-console
|
|
164
|
+
console.warn(
|
|
165
|
+
`[${PROFILE}] ${FETCH_URL} did not fetch (outcome=${run.outcome ?? 'n/a'}); no public egress — success-path coverage skipped (the blocked floor covers emission)`,
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
expect(
|
|
170
|
+
typeof run.runId === 'string' && (run.runId as string).length > 0,
|
|
171
|
+
driver.describe(CITE, 'the safe-fetch-run seam MUST return the runId of the real run it executed the fetch in'),
|
|
172
|
+
).toBe(true);
|
|
173
|
+
await assertDurableHttpPair(run.runId as string, 'fetched');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -384,16 +384,32 @@ function extractReadmeDocumentIndex(readme: string): string {
|
|
|
384
384
|
return readme.slice(start, end);
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
function listMarkdownFilesRecursive(dir: string): string[] {
|
|
387
|
+
function listMarkdownFilesRecursive(dir: string, repoRoot: string = dir): string[] {
|
|
388
388
|
const ignoredDirs = new Set(['.git', 'node_modules', 'dist']);
|
|
389
|
+
// Repo-relative directory paths to prune. These are subtrees whose
|
|
390
|
+
// content shouldn't be link-checked because either (a) they're
|
|
391
|
+
// generated build output (`site/out`) or (b) they're a vendored
|
|
392
|
+
// mirror of a canonical source whose READMEs use links relative to
|
|
393
|
+
// the canonical path, not the vendored path:
|
|
394
|
+
//
|
|
395
|
+
// - `apps/workflow-engine/packs/` mirrors repo-root `packs/`, synced
|
|
396
|
+
// via `apps/workflow-engine/scripts/sync-packs.sh` so the Cloud
|
|
397
|
+
// Run image's `apps/workflow-engine/` build context can ship them.
|
|
398
|
+
// Pack READMEs use `../../RFCS/...` / `../../spec/v1/...` links
|
|
399
|
+
// that resolve from the canonical location (which this walker
|
|
400
|
+
// DOES check) but break from the deeper vendored path. The
|
|
401
|
+
// canonical copies are authoritative; the vendored copies are
|
|
402
|
+
// byte-for-byte identical via cp -R.
|
|
403
|
+
const prunedRepoRelative = new Set(['site/out', 'apps/workflow-engine/packs']);
|
|
389
404
|
const files: string[] = [];
|
|
390
405
|
|
|
391
406
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
392
407
|
if (entry.isDirectory()) {
|
|
393
408
|
if (ignoredDirs.has(entry.name)) continue;
|
|
394
409
|
const child = join(dir, entry.name);
|
|
395
|
-
|
|
396
|
-
|
|
410
|
+
const repoRelChild = relative(repoRoot, child);
|
|
411
|
+
if (prunedRepoRelative.has(repoRelChild)) continue;
|
|
412
|
+
files.push(...listMarkdownFilesRecursive(child, repoRoot));
|
|
397
413
|
continue;
|
|
398
414
|
}
|
|
399
415
|
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable tool catalog — descriptor + capability + session-event shapes (RFC 0078).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free schema-shape probe. Verifies that:
|
|
5
|
+
* - `tool-descriptor.schema.json` compiles and round-trips a conforming
|
|
6
|
+
* `ToolDescriptor`, and rejects a descriptor missing the REQUIRED
|
|
7
|
+
* `safetyTier`.
|
|
8
|
+
* - the §C-1 / §F-4 cross-field MUST is enforced: a `safetyTier: "exec"`
|
|
9
|
+
* descriptor MUST carry `source: "host-extension"` (RFC 0069 — exec is never
|
|
10
|
+
* protocol-tier); an `exec` + `node-pack` descriptor is rejected, an `exec`
|
|
11
|
+
* + `host-extension` descriptor is accepted.
|
|
12
|
+
* - `capabilities.toolCatalog` is declared with its `supported` / `sources` /
|
|
13
|
+
* `sessionLifecycle` sub-flags.
|
|
14
|
+
* - the `tool.session.opened` / `tool.session.closed` payload $defs validate
|
|
15
|
+
* conforming content-free records and reject malformed ones (a `closed`
|
|
16
|
+
* missing `outcome`; an out-of-enum `outcome`), and both event names appear
|
|
17
|
+
* in the RunEventType enum.
|
|
18
|
+
*
|
|
19
|
+
* Behavioral assertions (a live `GET /v1/tools` returning authorization-scoped
|
|
20
|
+
* descriptors, the `404` non-disclosure, the `tool.session.*` bracket ordering)
|
|
21
|
+
* are gated on `capabilities.toolCatalog.supported` and land in
|
|
22
|
+
* `tool-catalog-projection.test.ts` + `tool-session-lifecycle.test.ts` (deferred
|
|
23
|
+
* per RFC 0078 §Conformance — reference host deferred). This scenario asserts the
|
|
24
|
+
* wire contract, not host behavior.
|
|
25
|
+
*
|
|
26
|
+
* Spec references:
|
|
27
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/tool-catalog.md
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0078-portable-tool-catalog-and-tool-session-contract.md
|
|
29
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0069-exec-class-tool-host-extension-safety-contract.md (exec ⇒ host-extension)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
36
|
+
import addFormats from 'ajv-formats';
|
|
37
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
38
|
+
|
|
39
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
40
|
+
|
|
41
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
42
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('tool-descriptor-shape: ToolDescriptor (RFC 0078 §C, server-free)', () => {
|
|
46
|
+
const ajv = addFormats(new Ajv2020({ strict: false }));
|
|
47
|
+
const validate = ajv.compile(loadSchema('tool-descriptor.schema.json'));
|
|
48
|
+
|
|
49
|
+
it('a conforming descriptor validates', () => {
|
|
50
|
+
expect(
|
|
51
|
+
validate({
|
|
52
|
+
toolId: 'mcp:fs.read', source: 'mcp', title: 'Read file',
|
|
53
|
+
inputSchema: { type: 'object' }, auth: { scopes: ['tools:fs:read'] },
|
|
54
|
+
egress: 'none', approval: 'never', replayPolicy: 'idempotent',
|
|
55
|
+
safetyTier: 'read', costHint: 'low', latencyHint: 'low',
|
|
56
|
+
}),
|
|
57
|
+
why('tool-catalog.md §C', 'a conforming ToolDescriptor MUST validate'),
|
|
58
|
+
).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('a descriptor missing the REQUIRED safetyTier is rejected', () => {
|
|
62
|
+
expect(
|
|
63
|
+
validate({ toolId: 'x', source: 'mcp' }),
|
|
64
|
+
why('tool-catalog.md §C', 'safetyTier is REQUIRED'),
|
|
65
|
+
).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('enforces exec ⇒ host-extension (RFC 0069; §C-1/§F-4)', () => {
|
|
69
|
+
expect(
|
|
70
|
+
validate({ toolId: 'x-host-acme-shell', source: 'host-extension', safetyTier: 'exec', approval: 'always', egress: 'host-owned' }),
|
|
71
|
+
why('tool-catalog.md §C-1', 'an exec tool sourced from host-extension MUST validate'),
|
|
72
|
+
).toBe(true);
|
|
73
|
+
expect(
|
|
74
|
+
validate({ toolId: 'openwop:run-shell', source: 'node-pack', safetyTier: 'exec' }),
|
|
75
|
+
why('tool-catalog.md §C-1 / RFC 0069', 'an exec tool MUST NOT be protocol-tier (node-pack)'),
|
|
76
|
+
).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects an unknown property (additionalProperties:false)', () => {
|
|
80
|
+
expect(
|
|
81
|
+
validate({ toolId: 'x', source: 'mcp', safetyTier: 'read', danger: true }),
|
|
82
|
+
why('tool-catalog.md §C', 'ToolDescriptor MUST be additionalProperties:false'),
|
|
83
|
+
).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('tool-descriptor-shape: capability advertisement (RFC 0078 §A, server-free)', () => {
|
|
88
|
+
it('capabilities.toolCatalog is declared with its sub-flags', () => {
|
|
89
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
90
|
+
const toolCatalog = (caps.properties as Record<string, { properties?: Record<string, unknown> }>).toolCatalog;
|
|
91
|
+
expect(
|
|
92
|
+
toolCatalog,
|
|
93
|
+
why('capabilities.md §toolCatalog', 'capabilities.toolCatalog MUST be declared'),
|
|
94
|
+
).toBeDefined();
|
|
95
|
+
for (const flag of ['supported', 'sources', 'sessionLifecycle']) {
|
|
96
|
+
expect(
|
|
97
|
+
toolCatalog?.properties?.[flag],
|
|
98
|
+
why('tool-catalog.md §A', `capabilities.toolCatalog.${flag} MUST be declared`),
|
|
99
|
+
).toBeDefined();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('tool-descriptor-shape: session lifecycle events (RFC 0078 §D, server-free)', () => {
|
|
105
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
106
|
+
const ajv = addFormats(new Ajv2020({ strict: false }));
|
|
107
|
+
const compile = (defName: string) => ajv.compile({
|
|
108
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
109
|
+
$defs: (payloads as { $defs: Record<string, unknown> }).$defs,
|
|
110
|
+
$ref: `#/$defs/${defName}`,
|
|
111
|
+
} as Record<string, unknown>);
|
|
112
|
+
|
|
113
|
+
it('tool.session.opened validates a content-free record', () => {
|
|
114
|
+
const v = compile('toolSessionOpened');
|
|
115
|
+
expect(v({ sessionId: 's1', toolId: 'mcp:fs.read' }), why('tool-catalog.md §D', 'opened MUST validate')).toBe(true);
|
|
116
|
+
expect(v({ toolId: 'mcp:fs.read' }), why('tool-catalog.md §D', 'opened requires sessionId')).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('tool.session.closed validates + enforces the closed outcome enum', () => {
|
|
120
|
+
const v = compile('toolSessionClosed');
|
|
121
|
+
expect(v({ sessionId: 's1', toolId: 'mcp:fs.read', outcome: 'completed' }), why('tool-catalog.md §D', 'closed MUST validate')).toBe(true);
|
|
122
|
+
expect(v({ sessionId: 's1', toolId: 'mcp:fs.read' }), why('tool-catalog.md §D', 'closed requires outcome')).toBe(false);
|
|
123
|
+
expect(v({ sessionId: 's1', toolId: 'mcp:fs.read', outcome: 'exploded' }), why('tool-catalog.md §D', 'outcome is a closed enum')).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('both session event names appear in the RunEventType enum', () => {
|
|
127
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
128
|
+
const enumVals = ((runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum) ?? [];
|
|
129
|
+
for (const name of ['tool.session.opened', 'tool.session.closed']) {
|
|
130
|
+
expect(enumVals.includes(name), why('run-event.schema.json', `${name} MUST be in the RunEventType enum`)).toBe(true);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable trigger bridge — delivery model (RFC 0083 §C) — behavioral.
|
|
3
|
+
*
|
|
4
|
+
* Profile-gated on `openwop-trigger-bridge` (derived from the live discovery
|
|
5
|
+
* doc per RFC 0083 §D: the bridge advertised + a dead-letter sink + a durable
|
|
6
|
+
* source). Soft-skips when the profile isn't derived (default) / hard-fails
|
|
7
|
+
* under `OPENWOP_REQUIRE_BEHAVIOR=true`. The always-on wire-shape coverage
|
|
8
|
+
* lives in `trigger-bridge-shape.test.ts`; this asserts host BEHAVIOR via the
|
|
9
|
+
* `POST /v1/host/sample/trigger-bridge/deliver` seam + the test event-log seam:
|
|
10
|
+
*
|
|
11
|
+
* 1. DEDUP (§C-1) — the same `dedupKey` delivered twice is effectively-once:
|
|
12
|
+
* exactly one `trigger.delivery.attempted { outcome:"delivered" }` for that
|
|
13
|
+
* key (at-least-once collapses to once within the retention window).
|
|
14
|
+
* 2. RETRY → DEAD-LETTER (§C-2 + RFC 0053) — an exhausted retry policy lands a
|
|
15
|
+
* terminal `trigger.delivery.attempted { outcome:"dead-lettered" }` and a
|
|
16
|
+
* `trigger.subscription.state.changed { toState:"dead-lettered" }`; both
|
|
17
|
+
* content-free (SR-1: ids/states/counters only).
|
|
18
|
+
* 3. CAUSATION (§C / RFC 0040) — a successful delivery's resulting run carries
|
|
19
|
+
* `run.started.causationId` == the delivery id (trigger → run is resolvable
|
|
20
|
+
* via `/ancestry`).
|
|
21
|
+
*
|
|
22
|
+
* Each leg soft-skips independently (seam absent / event-log seam absent).
|
|
23
|
+
*
|
|
24
|
+
* Spec references:
|
|
25
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/trigger-bridge.md (§C)
|
|
26
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0083-durable-trigger-and-channel-bridge-profile.md
|
|
27
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/profiles.md (§openwop-trigger-bridge)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
33
|
+
import {
|
|
34
|
+
isTriggerBridgeProfileAdvertised,
|
|
35
|
+
driveDelivery,
|
|
36
|
+
DELIVERY_OUTCOMES,
|
|
37
|
+
SUBSCRIPTION_STATES,
|
|
38
|
+
} from '../lib/triggerBridge.js';
|
|
39
|
+
import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
40
|
+
|
|
41
|
+
const CONTENT_FREE_FORBIDDEN = ['body', 'headers', 'payload', 'secret', 'credentials', 'token', 'apiKey'];
|
|
42
|
+
|
|
43
|
+
function expectContentFree(payload: Record<string, unknown>, where: string): void {
|
|
44
|
+
for (const f of CONTENT_FREE_FORBIDDEN) {
|
|
45
|
+
expect(
|
|
46
|
+
!(f in payload),
|
|
47
|
+
driver.describe('RFC 0083 §C (SR-1)', `${where} MUST be content-free (no ${f})`),
|
|
48
|
+
).toBe(true);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
|
|
53
|
+
it('de-dups by dedupKey, retries to dead-letter, and links delivery→run causation', async () => {
|
|
54
|
+
if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
|
|
55
|
+
if (!(await isEventLogSeamAvailable())) return; // event-log seam absent — soft-skip
|
|
56
|
+
|
|
57
|
+
// ---- Leg 1: dedup → effectively-once (§C-1) ---------------------------
|
|
58
|
+
const dedup = await driveDelivery({ scenario: 'dedup', dedupKey: 'conformance-dedup-key', source: 'queue' });
|
|
59
|
+
if (dedup === null) return; // delivery seam unwired — soft-skip the whole behavioral suite
|
|
60
|
+
if (dedup.runId || dedup.subscriptionId) {
|
|
61
|
+
const subId = dedup.subscriptionId;
|
|
62
|
+
const q = await queryTestEvents(dedup.runId ?? '__dedup__', { type: 'trigger.delivery.attempted' });
|
|
63
|
+
if (q.ok) {
|
|
64
|
+
const deliveredForKey = q.events.filter(
|
|
65
|
+
(e) => e.payload.dedupKey === 'conformance-dedup-key' && e.payload.outcome === 'delivered',
|
|
66
|
+
);
|
|
67
|
+
// Effectively-once: a repeated dedupKey MUST NOT produce two 'delivered' attempts.
|
|
68
|
+
expect(
|
|
69
|
+
deliveredForKey.length <= 1,
|
|
70
|
+
driver.describe('trigger-bridge.md §C-1', 'a repeated dedupKey MUST be effectively-once (≤1 delivered attempt)'),
|
|
71
|
+
).toBe(true);
|
|
72
|
+
for (const e of q.events) {
|
|
73
|
+
expect(
|
|
74
|
+
typeof e.payload.outcome === 'string' && DELIVERY_OUTCOMES.includes(e.payload.outcome as string),
|
|
75
|
+
driver.describe('run-event-payloads.schema.json#triggerDeliveryAttempted', 'outcome MUST be delivered|retrying|dead-lettered'),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
expectContentFree(e.payload, 'trigger.delivery.attempted');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
void subId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- Leg 2: retry → dead-letter (§C-2 + RFC 0053) --------------------
|
|
84
|
+
const exhaust = await driveDelivery({ scenario: 'exhaust', source: 'webhook' });
|
|
85
|
+
if (exhaust && (exhaust.runId || exhaust.subscriptionId)) {
|
|
86
|
+
const key = exhaust.runId ?? '__exhaust__';
|
|
87
|
+
const dq = await queryTestEvents(key, { type: 'trigger.delivery.attempted' });
|
|
88
|
+
if (dq.ok && dq.events.length > 0) {
|
|
89
|
+
const terminal = dq.events.sort((a, b) => a.sequence - b.sequence)[dq.events.length - 1]!;
|
|
90
|
+
expect(
|
|
91
|
+
terminal.payload.outcome === 'dead-lettered',
|
|
92
|
+
driver.describe('trigger-bridge.md §C-2', 'an exhausted retry policy MUST terminate in a dead-lettered delivery'),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
}
|
|
95
|
+
const sq = await queryTestEvents(key, { type: 'trigger.subscription.state.changed' });
|
|
96
|
+
if (sq.ok && sq.events.length > 0) {
|
|
97
|
+
const toDeadLetter = sq.events.some((e) => e.payload.toState === 'dead-lettered');
|
|
98
|
+
expect(
|
|
99
|
+
toDeadLetter,
|
|
100
|
+
driver.describe('trigger-bridge.md §B', 'the subscription MUST transition to dead-lettered on exhaustion'),
|
|
101
|
+
).toBe(true);
|
|
102
|
+
for (const e of sq.events) {
|
|
103
|
+
expect(
|
|
104
|
+
typeof e.payload.toState === 'string' && SUBSCRIPTION_STATES.includes(e.payload.toState as string),
|
|
105
|
+
driver.describe('trigger-bridge.md §B', 'toState MUST be in the four-state vocabulary'),
|
|
106
|
+
).toBe(true);
|
|
107
|
+
expectContentFree(e.payload, 'trigger.subscription.state.changed');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- Leg 3: delivery → run causation (§C / RFC 0040) -----------------
|
|
113
|
+
const delivered = await driveDelivery({ scenario: 'deliver', source: 'schedule' });
|
|
114
|
+
if (delivered?.runId) {
|
|
115
|
+
const rq = await queryTestEvents(delivered.runId, { type: 'run.started' });
|
|
116
|
+
if (rq.ok && rq.events[0]) {
|
|
117
|
+
expect(
|
|
118
|
+
typeof rq.events[0].causationId === 'string' && (rq.events[0].causationId as string).length > 0,
|
|
119
|
+
driver.describe('trigger-bridge.md §C / RFC 0040', 'the delivered run.started MUST carry the delivery causationId (resolvable via /ancestry)'),
|
|
120
|
+
).toBe(true);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await resetTestSeam();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable trigger + channel bridge — subscription + events + profile shapes (RFC 0083).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free schema-shape probe. Verifies that:
|
|
5
|
+
* - `trigger-subscription.schema.json` round-trips a conforming
|
|
6
|
+
* `TriggerSubscription` and rejects the malformed (missing REQUIRED `state`;
|
|
7
|
+
* an out-of-enum `source`; an unknown property under
|
|
8
|
+
* `additionalProperties:false`).
|
|
9
|
+
* - the four-state vocabulary (`active`/`paused`/`failed`/`dead-lettered`) is
|
|
10
|
+
* stable on the subscription `state` + the event `fromState`/`toState`.
|
|
11
|
+
* - the `trigger.subscription.state.changed` + `trigger.delivery.attempted`
|
|
12
|
+
* payload $defs validate conforming content-free records and reject malformed
|
|
13
|
+
* ones (a missing `outcome`; an out-of-enum `outcome`), and both event names
|
|
14
|
+
* appear in the RunEventType enum.
|
|
15
|
+
* - `capabilities.triggerBridge` (+ `webhooks.durable`) is declared.
|
|
16
|
+
* - `deriveProfiles` surfaces `openwop-trigger-bridge` for a host advertising
|
|
17
|
+
* the bridge + a dead-letter sink + a durable source, and withholds it when
|
|
18
|
+
* the dead-letter sink is absent (the §D predicate's OR + sink requirement).
|
|
19
|
+
*
|
|
20
|
+
* Behavioral assertions (the dedup → retry → dead-letter → causation delivery
|
|
21
|
+
* loop) are gated on the `openwop-trigger-bridge` profile and land in
|
|
22
|
+
* `trigger-bridge-delivery.test.ts` (deferred per RFC 0083 §Conformance —
|
|
23
|
+
* reference host deferred). This scenario asserts the wire contract, not host
|
|
24
|
+
* behavior.
|
|
25
|
+
*
|
|
26
|
+
* Spec references:
|
|
27
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/trigger-bridge.md
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/profiles.md (§`openwop-trigger-bridge`)
|
|
29
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0083-durable-trigger-and-channel-bridge-profile.md
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
36
|
+
import addFormats from 'ajv-formats';
|
|
37
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
38
|
+
import { deriveProfiles } from '../lib/profiles.js';
|
|
39
|
+
|
|
40
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
41
|
+
|
|
42
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
43
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const STATES = ['active', 'paused', 'failed', 'dead-lettered'] as const;
|
|
47
|
+
|
|
48
|
+
describe('trigger-bridge-shape: TriggerSubscription (RFC 0083 §B, server-free)', () => {
|
|
49
|
+
const ajv = addFormats(new Ajv2020({ strict: false }));
|
|
50
|
+
const sub = loadSchema('trigger-subscription.schema.json');
|
|
51
|
+
const validate = ajv.compile(sub);
|
|
52
|
+
|
|
53
|
+
it('a conforming subscription validates', () => {
|
|
54
|
+
expect(
|
|
55
|
+
validate({ subscriptionId: 'sub-1', source: 'webhook', state: 'active', dedupEnabled: true, retryPolicy: { maxAttempts: 8, backoff: 'exponential' }, webhookId: 'wh-1', secretFingerprint: 'fp-abc' }),
|
|
56
|
+
why('trigger-bridge.md §B', 'a conforming TriggerSubscription MUST validate'),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects a missing REQUIRED state, an out-of-enum source, and an unknown property', () => {
|
|
61
|
+
expect(validate({ subscriptionId: 's', source: 'webhook' }), why('trigger-bridge.md §B', 'state is REQUIRED')).toBe(false);
|
|
62
|
+
expect(validate({ subscriptionId: 's', source: 'carrier-pigeon', state: 'active' }), why('trigger-bridge.md §B', 'source is a closed enum')).toBe(false);
|
|
63
|
+
expect(validate({ subscriptionId: 's', source: 'webhook', state: 'active', body: 'inbound' }), why('trigger-bridge.md §B', 'TriggerSubscription MUST be additionalProperties:false')).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('secretFingerprint MUST be bounded — a full 64-hex (unsalted-hash-smelling) digest is rejected', () => {
|
|
67
|
+
const truncated = 'a1b2c3d4e5f6a7b8'; // 16 hex — a truncated host-keyed fingerprint
|
|
68
|
+
const fullDigest = 'a'.repeat(64); // 64 hex — smells like an unsalted SHA256(secret)
|
|
69
|
+
expect(validate({ subscriptionId: 's', source: 'webhook', state: 'active', secretFingerprint: truncated }), why('trigger-bridge.md §B', 'a truncated fingerprint MUST validate')).toBe(true);
|
|
70
|
+
expect(validate({ subscriptionId: 's', source: 'webhook', state: 'active', secretFingerprint: fullDigest }), why('SR-1', 'a full 64-hex digest MUST be rejected (brute-force oracle)')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('the state enum is exactly the four §B states', () => {
|
|
74
|
+
const stateEnum = ((sub.properties as Record<string, { enum?: string[] }>).state?.enum) ?? [];
|
|
75
|
+
expect([...stateEnum].sort(), why('trigger-bridge.md §B', 'the four-state vocabulary MUST be stable')).toEqual([...STATES].sort());
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('trigger-bridge-shape: trigger.* events (RFC 0083 §C, server-free)', () => {
|
|
80
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
81
|
+
const ajv = addFormats(new Ajv2020({ strict: false }));
|
|
82
|
+
const compile = (defName: string) => ajv.compile({
|
|
83
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
84
|
+
$defs: (payloads as { $defs: Record<string, unknown> }).$defs,
|
|
85
|
+
$ref: `#/$defs/${defName}`,
|
|
86
|
+
} as Record<string, unknown>);
|
|
87
|
+
|
|
88
|
+
it('trigger.subscription.state.changed validates + reason is a CLOSED enum (no URL-bearing free text)', () => {
|
|
89
|
+
const v = compile('triggerSubscriptionStateChanged');
|
|
90
|
+
expect(v({ subscriptionId: 's', source: 'webhook', fromState: 'active', toState: 'dead-lettered', reason: 'retry-exhausted' }), why('trigger-bridge.md §C', 'state-changed MUST validate')).toBe(true);
|
|
91
|
+
expect(v({ subscriptionId: 's', source: 'webhook', fromState: 'active' }), why('trigger-bridge.md §C', 'toState is REQUIRED')).toBe(false);
|
|
92
|
+
expect(v({ subscriptionId: 's', source: 'webhook', fromState: 'active', toState: 'failed', reason: 'https://attacker.example/leak?token=sk' }), why('SR-1', 'a free-form / URL-bearing reason MUST be rejected')).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('trigger.delivery.attempted validates + enforces the outcome enum', () => {
|
|
96
|
+
const v = compile('triggerDeliveryAttempted');
|
|
97
|
+
expect(v({ subscriptionId: 's', dedupKey: 'evt-9f3', attempt: 1, outcome: 'delivered', runId: 'run_x' }), why('trigger-bridge.md §C', 'delivery-attempted MUST validate')).toBe(true);
|
|
98
|
+
expect(v({ subscriptionId: 's', dedupKey: 'evt-9f3', attempt: 1 }), why('trigger-bridge.md §C', 'outcome is REQUIRED')).toBe(false);
|
|
99
|
+
expect(v({ subscriptionId: 's', dedupKey: 'evt-9f3', attempt: 1, outcome: 'exploded' }), why('trigger-bridge.md §C', 'outcome is a closed enum')).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('both trigger event names appear in the RunEventType enum', () => {
|
|
103
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
104
|
+
const enumVals = ((runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum) ?? [];
|
|
105
|
+
for (const name of ['trigger.subscription.state.changed', 'trigger.delivery.attempted']) {
|
|
106
|
+
expect(enumVals.includes(name), why('run-event.schema.json', `${name} MUST be in the RunEventType enum`)).toBe(true);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('trigger-bridge-shape: capability + profile derivation (RFC 0083 §A/§D, server-free)', () => {
|
|
112
|
+
it('capabilities.triggerBridge + webhooks.durable are declared', () => {
|
|
113
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
114
|
+
const props = caps.properties as Record<string, { properties?: Record<string, unknown> }>;
|
|
115
|
+
expect(props.triggerBridge?.properties?.supported, why('trigger-bridge.md §A', 'triggerBridge.supported MUST be declared')).toBeDefined();
|
|
116
|
+
expect(props.webhooks?.properties?.durable, why('trigger-bridge.md §A', 'webhooks.durable MUST be declared')).toBeDefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const coreBase = {
|
|
120
|
+
protocolVersion: '1.0',
|
|
121
|
+
supportedEnvelopes: ['clarification.request'],
|
|
122
|
+
schemaVersions: {},
|
|
123
|
+
limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
it('deriveProfiles surfaces openwop-trigger-bridge for bridge + deadLetter + a durable source', () => {
|
|
127
|
+
const c = { ...coreBase, triggerBridge: { supported: true }, deadLetter: { supported: true }, queueBus: { supported: true } } as Record<string, unknown>;
|
|
128
|
+
expect(deriveProfiles(c).includes('openwop-trigger-bridge'), why('profiles.md §openwop-trigger-bridge', 'bridge + sink + durable source MUST derive the profile')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('deriveProfiles withholds openwop-trigger-bridge when the dead-letter sink is absent', () => {
|
|
132
|
+
const c = { ...coreBase, triggerBridge: { supported: true }, webhooks: { durable: true } } as Record<string, unknown>;
|
|
133
|
+
expect(deriveProfiles(c).includes('openwop-trigger-bridge'), why('profiles.md §openwop-trigger-bridge', 'no deadLetter sink ⇒ MUST NOT derive the profile')).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|