@openwop/openwop-conformance 1.0.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/LICENSE +201 -0
- package/README.md +241 -0
- package/api/asyncapi.yaml +481 -0
- package/api/openapi.yaml +830 -0
- package/api/redocly.yaml +8 -0
- package/coverage.md +80 -0
- package/dist/cli.js +161 -0
- package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
- package/fixtures/conformance-agent-identity.json +27 -0
- package/fixtures/conformance-agent-low-confidence.json +29 -0
- package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
- package/fixtures/conformance-agent-memory-redaction.json +32 -0
- package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
- package/fixtures/conformance-agent-memory-ttl.json +31 -0
- package/fixtures/conformance-agent-pack-export.json +26 -0
- package/fixtures/conformance-agent-pack-install.json +26 -0
- package/fixtures/conformance-agent-pack-provenance.json +31 -0
- package/fixtures/conformance-agent-reasoning.json +29 -0
- package/fixtures/conformance-approval.json +27 -0
- package/fixtures/conformance-cancellable.json +33 -0
- package/fixtures/conformance-cap-breach.json +27 -0
- package/fixtures/conformance-capability-missing.json +23 -0
- package/fixtures/conformance-channel-ttl.json +60 -0
- package/fixtures/conformance-clarification.json +30 -0
- package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
- package/fixtures/conformance-conversation-lifecycle.json +32 -0
- package/fixtures/conformance-conversation-replay.json +33 -0
- package/fixtures/conformance-conversation-vs-clarification.json +26 -0
- package/fixtures/conformance-delay.json +33 -0
- package/fixtures/conformance-dispatch-loop.json +38 -0
- package/fixtures/conformance-failure.json +23 -0
- package/fixtures/conformance-idempotent.json +30 -0
- package/fixtures/conformance-identity.json +32 -0
- package/fixtures/conformance-interrupt-auth-required.json +28 -0
- package/fixtures/conformance-interrupt-external-event.json +33 -0
- package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
- package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
- package/fixtures/conformance-interrupt-quorum.json +30 -0
- package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
- package/fixtures/conformance-message-reducer.json +31 -0
- package/fixtures/conformance-multi-node.json +21 -0
- package/fixtures/conformance-noop.json +23 -0
- package/fixtures/conformance-orchestrator-dispatch.json +47 -0
- package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
- package/fixtures/conformance-orchestrator-terminate.json +44 -0
- package/fixtures/conformance-stream-text.json +26 -0
- package/fixtures/conformance-subworkflow-child.json +21 -0
- package/fixtures/conformance-subworkflow-parent.json +49 -0
- package/fixtures/conformance-version-fold.json +23 -0
- package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
- package/fixtures/pack-manifests/pack-private-example.json +26 -0
- package/fixtures.md +404 -0
- package/package.json +48 -0
- package/schemas/README.md +75 -0
- package/schemas/agent-manifest.schema.json +107 -0
- package/schemas/agent-ref.schema.json +53 -0
- package/schemas/capabilities.schema.json +287 -0
- package/schemas/channel-written-payload.schema.json +55 -0
- package/schemas/conversation-event.schema.json +120 -0
- package/schemas/conversation-turn.schema.json +72 -0
- package/schemas/debug-bundle.schema.json +196 -0
- package/schemas/dispatch-config.schema.json +46 -0
- package/schemas/error-envelope.schema.json +25 -0
- package/schemas/memory-entry.schema.json +36 -0
- package/schemas/memory-list-options.schema.json +21 -0
- package/schemas/node-pack-manifest.schema.json +235 -0
- package/schemas/orchestrator-decision.schema.json +60 -0
- package/schemas/run-event-payloads.schema.json +663 -0
- package/schemas/run-event.schema.json +116 -0
- package/schemas/run-options.schema.json +81 -0
- package/schemas/run-orchestrator-decided-event.schema.json +20 -0
- package/schemas/run-snapshot.schema.json +121 -0
- package/schemas/suspend-request.schema.json +182 -0
- package/schemas/workflow-definition.schema.json +430 -0
- package/src/cli.ts +187 -0
- package/src/lib/a2a-fake-peer.ts +233 -0
- package/src/lib/canaries.ts +186 -0
- package/src/lib/driver.ts +96 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/fixtures.ts +93 -0
- package/src/lib/mcp-fake-server.ts +185 -0
- package/src/lib/multi-agent-capabilities.ts +155 -0
- package/src/lib/multiProcess.ts +141 -0
- package/src/lib/otel-collector.ts +312 -0
- package/src/lib/paths.ts +198 -0
- package/src/lib/polling.ts +81 -0
- package/src/lib/profiles.ts +258 -0
- package/src/lib/sse.ts +172 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
- package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
- package/src/scenarios/agentMessageReducer.test.ts +57 -0
- package/src/scenarios/agentMetadata.test.ts +56 -0
- package/src/scenarios/agentPackExport.test.ts +45 -0
- package/src/scenarios/agentPackInstall.test.ts +50 -0
- package/src/scenarios/agentPackProvenance.test.ts +53 -0
- package/src/scenarios/agentReasoningEvents.test.ts +72 -0
- package/src/scenarios/append-ordering.test.ts +91 -0
- package/src/scenarios/approval-payload.test.ts +120 -0
- package/src/scenarios/audit-log-integrity.test.ts +106 -0
- package/src/scenarios/auth.test.ts +55 -0
- package/src/scenarios/byok-roundtrip.test.ts +166 -0
- package/src/scenarios/cancellation.test.ts +68 -0
- package/src/scenarios/cap-breach.test.ts +149 -0
- package/src/scenarios/channel-ttl.test.ts +70 -0
- package/src/scenarios/configurable-schema.test.ts +76 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
- package/src/scenarios/conversationLifecycle.test.ts +64 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
- package/src/scenarios/cost-attribution.test.ts +207 -0
- package/src/scenarios/debugBundle.test.ts +222 -0
- package/src/scenarios/discovery.test.ts +147 -0
- package/src/scenarios/dispatchLoop.test.ts +52 -0
- package/src/scenarios/errors.test.ts +144 -0
- package/src/scenarios/eventOrdering.test.ts +144 -0
- package/src/scenarios/failure-path.test.ts +46 -0
- package/src/scenarios/fixtures-gating.test.ts +137 -0
- package/src/scenarios/fixtures-valid.test.ts +140 -0
- package/src/scenarios/highConcurrency.test.ts +263 -0
- package/src/scenarios/idempotency.test.ts +83 -0
- package/src/scenarios/idempotencyRetry.test.ts +130 -0
- package/src/scenarios/identity-passthrough.test.ts +54 -0
- package/src/scenarios/interrupt-approval.test.ts +97 -0
- package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
- package/src/scenarios/interrupt-clarification.test.ts +45 -0
- package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
- package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
- package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
- package/src/scenarios/interruptRace.test.ts +176 -0
- package/src/scenarios/maliciousManifest.test.ts +154 -0
- package/src/scenarios/mcp-discoverability.test.ts +129 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
- package/src/scenarios/multi-node-ordering.test.ts +60 -0
- package/src/scenarios/multi-region-idempotency.test.ts +52 -0
- package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
- package/src/scenarios/orchestratorDispatch.test.ts +66 -0
- package/src/scenarios/orchestratorTermination.test.ts +54 -0
- package/src/scenarios/otel-emission.test.ts +113 -0
- package/src/scenarios/otel-trace-propagation.test.ts +90 -0
- package/src/scenarios/pack-registry-publish.test.ts +93 -0
- package/src/scenarios/pack-registry.test.ts +328 -0
- package/src/scenarios/pause-resume.test.ts +109 -0
- package/src/scenarios/policies.test.ts +162 -0
- package/src/scenarios/profileDerivation.test.ts +335 -0
- package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
- package/src/scenarios/rate-limit-envelope.test.ts +97 -0
- package/src/scenarios/redaction.test.ts +254 -0
- package/src/scenarios/redactionAdversarial.test.ts +162 -0
- package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
- package/src/scenarios/replay-fork.test.ts +216 -0
- package/src/scenarios/replayDeterminism.test.ts +171 -0
- package/src/scenarios/route-coverage.test.ts +129 -0
- package/src/scenarios/runs-lifecycle.test.ts +65 -0
- package/src/scenarios/runtime-capabilities.test.ts +118 -0
- package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
- package/src/scenarios/staleClaim.test.ts +223 -0
- package/src/scenarios/stream-modes-buffer.test.ts +148 -0
- package/src/scenarios/stream-modes-mixed.test.ts +149 -0
- package/src/scenarios/stream-modes.test.ts +139 -0
- package/src/scenarios/streamReconnect.test.ts +162 -0
- package/src/scenarios/subworkflow.test.ts +126 -0
- package/src/scenarios/version-negotiation.test.ts +157 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
- package/src/scenarios/wasm-pack-load.test.ts +75 -0
- package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
- package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
- package/src/setup.ts +173 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug-bundle scenarios per `spec/v1/debug-bundle.md`.
|
|
3
|
+
*
|
|
4
|
+
* GET /v1/runs/{runId}/debug-bundle returns a portable JSON snapshot
|
|
5
|
+
* of a single run's diagnostic state — run snapshot + events + spans
|
|
6
|
+
* + metrics + redaction state.
|
|
7
|
+
*
|
|
8
|
+
* Profile gating: hosts that don't advertise
|
|
9
|
+
* `capabilities.debugBundle.supported: true` skip-equivalent.
|
|
10
|
+
*
|
|
11
|
+
* What this scenario verifies:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Schema validity** — the response validates against
|
|
14
|
+
* `schemas/debug-bundle.schema.json`.
|
|
15
|
+
* 2. **Event-count invariant** — `metrics.eventCount` equals
|
|
16
|
+
* `events.length` (per debug-bundle.md §"Field reference").
|
|
17
|
+
* 3. **Bundle/event-stream agreement** — the events in the bundle
|
|
18
|
+
* match the events from `/events/poll` for the same run.
|
|
19
|
+
* 4. **Redaction marker validity** — `redactionApplied: true` MUST
|
|
20
|
+
* NOT coexist with `redactionMode: passthrough` (malformed shape
|
|
21
|
+
* per debug-bundle.md §"Redaction guarantees").
|
|
22
|
+
* 5. **Canary safety** — bundles MUST inherit redaction. A canary
|
|
23
|
+
* injected through workflow inputs MUST NOT echo verbatim in the
|
|
24
|
+
* bundle response.
|
|
25
|
+
*
|
|
26
|
+
* Cross-references SECURITY/invariants.yaml `secret-leakage-debug-bundle`.
|
|
27
|
+
*
|
|
28
|
+
* @see spec/v1/debug-bundle.md
|
|
29
|
+
* @see schemas/debug-bundle.schema.json
|
|
30
|
+
* @see SECURITY/threat-model-secret-leakage.md
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { driver } from '../lib/driver.js';
|
|
35
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
36
|
+
import { CANARY_MARKER, getCanary } from '../lib/canaries.js';
|
|
37
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
38
|
+
|
|
39
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
40
|
+
const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
|
|
41
|
+
|
|
42
|
+
interface DebugBundleShape {
|
|
43
|
+
bundleVersion?: unknown;
|
|
44
|
+
generatedAt?: unknown;
|
|
45
|
+
host?: { name?: unknown; version?: unknown };
|
|
46
|
+
run?: { runId?: unknown; status?: unknown };
|
|
47
|
+
events?: unknown[];
|
|
48
|
+
spans?: unknown[];
|
|
49
|
+
metrics?: { eventCount?: unknown; nodeCount?: unknown };
|
|
50
|
+
redactionApplied?: unknown;
|
|
51
|
+
redactionMode?: unknown;
|
|
52
|
+
truncated?: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function isAdvertised(): Promise<boolean> {
|
|
56
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
57
|
+
if (res.status !== 200) return false;
|
|
58
|
+
const body = res.json as { debugBundle?: { supported?: unknown } };
|
|
59
|
+
return body.debugBundle?.supported === true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe.skipIf(SKIP_NO_NOOP)('debug-bundle: GET /v1/runs/{runId}/debug-bundle response shape', () => {
|
|
63
|
+
it('host advertising capabilities.debugBundle.supported returns 200 with valid bundle', async () => {
|
|
64
|
+
if (!(await isAdvertised())) return; // skip-equivalent
|
|
65
|
+
|
|
66
|
+
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
67
|
+
expect(create.status).toBe(201);
|
|
68
|
+
const runId = (create.json as { runId: string }).runId;
|
|
69
|
+
await pollUntilTerminal(runId);
|
|
70
|
+
|
|
71
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/debug-bundle`);
|
|
72
|
+
expect(res.status, driver.describe(
|
|
73
|
+
'spec/v1/debug-bundle.md §Endpoint',
|
|
74
|
+
'host advertising debugBundle.supported MUST return 200 on /debug-bundle',
|
|
75
|
+
)).toBe(200);
|
|
76
|
+
|
|
77
|
+
const bundle = res.json as DebugBundleShape | undefined;
|
|
78
|
+
expect(bundle, driver.describe(
|
|
79
|
+
'spec/v1/debug-bundle.md',
|
|
80
|
+
'response MUST be JSON',
|
|
81
|
+
)).toBeDefined();
|
|
82
|
+
|
|
83
|
+
expect(typeof bundle?.bundleVersion, driver.describe(
|
|
84
|
+
'debug-bundle.md §Field reference',
|
|
85
|
+
'bundleVersion MUST be a string',
|
|
86
|
+
)).toBe('string');
|
|
87
|
+
expect(typeof bundle?.generatedAt, driver.describe(
|
|
88
|
+
'debug-bundle.md',
|
|
89
|
+
'generatedAt MUST be a string',
|
|
90
|
+
)).toBe('string');
|
|
91
|
+
expect(typeof bundle?.host?.name, driver.describe(
|
|
92
|
+
'debug-bundle.md',
|
|
93
|
+
'host.name MUST be a string',
|
|
94
|
+
)).toBe('string');
|
|
95
|
+
expect(typeof bundle?.host?.version, driver.describe(
|
|
96
|
+
'debug-bundle.md',
|
|
97
|
+
'host.version MUST be a string',
|
|
98
|
+
)).toBe('string');
|
|
99
|
+
expect(typeof bundle?.run?.runId, driver.describe(
|
|
100
|
+
'debug-bundle.md',
|
|
101
|
+
'run.runId MUST be a string',
|
|
102
|
+
)).toBe('string');
|
|
103
|
+
expect(Array.isArray(bundle?.events), driver.describe(
|
|
104
|
+
'debug-bundle.md',
|
|
105
|
+
'events MUST be an array',
|
|
106
|
+
)).toBe(true);
|
|
107
|
+
expect(typeof bundle?.redactionApplied, driver.describe(
|
|
108
|
+
'debug-bundle.md',
|
|
109
|
+
'redactionApplied MUST be a boolean',
|
|
110
|
+
)).toBe('boolean');
|
|
111
|
+
expect(typeof bundle?.redactionMode, driver.describe(
|
|
112
|
+
'debug-bundle.md',
|
|
113
|
+
'redactionMode MUST be a string',
|
|
114
|
+
)).toBe('string');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('hosts not advertising debugBundle return 404 on the endpoint', async () => {
|
|
118
|
+
if (await isAdvertised()) return; // skip-equivalent for hosts that DO advertise
|
|
119
|
+
|
|
120
|
+
// Use any runId — even a synthetic one — since the host should 404
|
|
121
|
+
// on the endpoint regardless of run existence.
|
|
122
|
+
const res = await driver.get('/v1/runs/openwop-conformance-no-such-run/debug-bundle');
|
|
123
|
+
expect(res.status, driver.describe(
|
|
124
|
+
'debug-bundle.md §Endpoint',
|
|
125
|
+
'host NOT advertising debugBundle.supported MUST return 404',
|
|
126
|
+
)).toBe(404);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe.skipIf(SKIP_NO_NOOP)('debug-bundle: invariants per debug-bundle.md', () => {
|
|
131
|
+
it('metrics.eventCount equals events.length', async () => {
|
|
132
|
+
if (!(await isAdvertised())) return;
|
|
133
|
+
|
|
134
|
+
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
135
|
+
expect(create.status).toBe(201);
|
|
136
|
+
const runId = (create.json as { runId: string }).runId;
|
|
137
|
+
await pollUntilTerminal(runId);
|
|
138
|
+
|
|
139
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/debug-bundle`);
|
|
140
|
+
expect(res.status).toBe(200);
|
|
141
|
+
const bundle = res.json as DebugBundleShape;
|
|
142
|
+
|
|
143
|
+
if (bundle.metrics?.eventCount !== undefined) {
|
|
144
|
+
expect(bundle.metrics.eventCount, driver.describe(
|
|
145
|
+
'debug-bundle.md §"Field reference"',
|
|
146
|
+
'metrics.eventCount MUST equal events.length',
|
|
147
|
+
)).toBe(bundle.events?.length ?? 0);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('redactionApplied=true is incompatible with redactionMode=passthrough', async () => {
|
|
152
|
+
if (!(await isAdvertised())) return;
|
|
153
|
+
|
|
154
|
+
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
155
|
+
expect(create.status).toBe(201);
|
|
156
|
+
const runId = (create.json as { runId: string }).runId;
|
|
157
|
+
await pollUntilTerminal(runId);
|
|
158
|
+
|
|
159
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/debug-bundle`);
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
const bundle = res.json as DebugBundleShape;
|
|
162
|
+
|
|
163
|
+
if (bundle.redactionApplied === true) {
|
|
164
|
+
expect(bundle.redactionMode, driver.describe(
|
|
165
|
+
'debug-bundle.md §"Redaction guarantees"',
|
|
166
|
+
'redactionApplied=true MUST NOT coexist with redactionMode=passthrough — that combination is malformed',
|
|
167
|
+
)).not.toBe('passthrough');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('bundle events agree with /events/poll for the same run', async () => {
|
|
172
|
+
if (!(await isAdvertised())) return;
|
|
173
|
+
|
|
174
|
+
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
175
|
+
expect(create.status).toBe(201);
|
|
176
|
+
const runId = (create.json as { runId: string }).runId;
|
|
177
|
+
await pollUntilTerminal(runId);
|
|
178
|
+
|
|
179
|
+
const bundleRes = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/debug-bundle`);
|
|
180
|
+
const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll`);
|
|
181
|
+
if (eventsRes.status !== 200) return; // host without polling
|
|
182
|
+
|
|
183
|
+
const bundle = bundleRes.json as DebugBundleShape;
|
|
184
|
+
const polledEvents = (eventsRes.json as { events?: unknown[] }).events ?? [];
|
|
185
|
+
|
|
186
|
+
expect(bundle.events?.length, driver.describe(
|
|
187
|
+
'debug-bundle.md',
|
|
188
|
+
'bundle event count MUST agree with /events/poll for the same run',
|
|
189
|
+
)).toBe(polledEvents.length);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe.skipIf(SKIP_NO_NOOP)('debug-bundle: redaction inheritance per SECURITY/invariants.yaml secret-leakage-debug-bundle', () => {
|
|
194
|
+
it('canary in workflow inputs MUST NOT appear verbatim in the bundle response', async () => {
|
|
195
|
+
if (!(await isAdvertised())) return;
|
|
196
|
+
|
|
197
|
+
const canary = getCanary('byok-credential-ref').value;
|
|
198
|
+
|
|
199
|
+
const create = await driver.post('/v1/runs', {
|
|
200
|
+
workflowId: NOOP_WORKFLOW_ID,
|
|
201
|
+
inputs: {
|
|
202
|
+
userField: canary,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
expect(create.status).toBe(201);
|
|
206
|
+
const runId = (create.json as { runId: string }).runId;
|
|
207
|
+
await pollUntilTerminal(runId);
|
|
208
|
+
|
|
209
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/debug-bundle`);
|
|
210
|
+
expect(res.status).toBe(200);
|
|
211
|
+
|
|
212
|
+
const bundleText = res.text;
|
|
213
|
+
expect(bundleText.includes(canary), driver.describe(
|
|
214
|
+
'SECURITY/invariants.yaml secret-leakage-debug-bundle',
|
|
215
|
+
'BYOK-shaped canary submitted as workflow input MUST NOT appear verbatim in the debug bundle',
|
|
216
|
+
)).toBe(false);
|
|
217
|
+
expect(bundleText.includes(CANARY_MARKER), driver.describe(
|
|
218
|
+
'SECURITY/invariants.yaml secret-leakage-debug-bundle',
|
|
219
|
+
'canary marker MUST NOT appear in the debug bundle',
|
|
220
|
+
)).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery scenarios — `/.well-known/openwop` and `/v1/openapi.json`.
|
|
3
|
+
*
|
|
4
|
+
* These are the only two endpoints that MUST work without authentication
|
|
5
|
+
* (per `auth.md` §2 + `rest-endpoints.md`). They're the cheapest cross-
|
|
6
|
+
* implementation contracts to verify.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { driver } from '../lib/driver.js';
|
|
11
|
+
|
|
12
|
+
describe('discovery: /.well-known/openwop', () => {
|
|
13
|
+
it('returns 200 with required Capabilities fields per capabilities.md §2', async () => {
|
|
14
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
15
|
+
|
|
16
|
+
expect(res.status, driver.describe(
|
|
17
|
+
'capabilities.md §2',
|
|
18
|
+
'discovery endpoint MUST be reachable without auth and return 200',
|
|
19
|
+
)).toBe(200);
|
|
20
|
+
|
|
21
|
+
const body = res.json as Record<string, unknown> | undefined;
|
|
22
|
+
expect(body, driver.describe('capabilities.md §2', 'response MUST be JSON')).toBeDefined();
|
|
23
|
+
|
|
24
|
+
// Per capabilities.md §3 (in-package shape), these 4 fields are REQUIRED.
|
|
25
|
+
for (const required of ['protocolVersion', 'supportedEnvelopes', 'schemaVersions', 'limits']) {
|
|
26
|
+
expect(body?.[required], driver.describe(
|
|
27
|
+
'capabilities.md §3',
|
|
28
|
+
`Capabilities.${required} MUST be present`,
|
|
29
|
+
)).toBeDefined();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('serves Cache-Control per capabilities.md §4 (caching guidance)', async () => {
|
|
34
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
35
|
+
const cacheControl = res.headers.get('cache-control');
|
|
36
|
+
|
|
37
|
+
expect(cacheControl, driver.describe(
|
|
38
|
+
'capabilities.md §4',
|
|
39
|
+
'response SHOULD carry a Cache-Control header to allow client caching',
|
|
40
|
+
)).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('IF Capabilities-Etag is present, it is non-empty and stable within the cache window', async () => {
|
|
44
|
+
const first = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
45
|
+
const firstEtag = first.headers.get('capabilities-etag');
|
|
46
|
+
|
|
47
|
+
if (firstEtag === null) {
|
|
48
|
+
expect(firstEtag, driver.describe(
|
|
49
|
+
'capabilities-change-detection.md §Capabilities-Etag',
|
|
50
|
+
'Capabilities-Etag is optional; hosts that omit it remain conformant',
|
|
51
|
+
)).toBeNull();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
expect(firstEtag.trim().length, driver.describe(
|
|
56
|
+
'capabilities-change-detection.md §Capabilities-Etag',
|
|
57
|
+
'Capabilities-Etag, when present, MUST be a non-empty opaque string',
|
|
58
|
+
)).toBeGreaterThan(0);
|
|
59
|
+
|
|
60
|
+
const second = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
61
|
+
const secondEtag = second.headers.get('capabilities-etag');
|
|
62
|
+
|
|
63
|
+
expect(secondEtag, driver.describe(
|
|
64
|
+
'capabilities-change-detection.md §Conformance expectations',
|
|
65
|
+
'repeated discovery calls without host changes SHOULD return the same Capabilities-Etag within the cache window',
|
|
66
|
+
)).toBe(firstEtag);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('declares non-zero limits per capabilities.md §3 (CapabilityLimiter shape)', async () => {
|
|
70
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
71
|
+
const limits = (res.json as { limits?: Record<string, number> } | undefined)?.limits;
|
|
72
|
+
|
|
73
|
+
expect(limits, driver.describe(
|
|
74
|
+
'capabilities.md §3',
|
|
75
|
+
'Capabilities.limits MUST be present',
|
|
76
|
+
)).toBeDefined();
|
|
77
|
+
|
|
78
|
+
for (const k of ['clarificationRounds', 'schemaRounds', 'envelopesPerTurn']) {
|
|
79
|
+
const v = limits?.[k];
|
|
80
|
+
expect(typeof v, driver.describe(
|
|
81
|
+
'capabilities.md §3',
|
|
82
|
+
`limits.${k} MUST be a non-negative integer`,
|
|
83
|
+
)).toBe('number');
|
|
84
|
+
expect(v ?? -1, driver.describe(
|
|
85
|
+
'capabilities.md §3',
|
|
86
|
+
`limits.${k} MUST be >= 0`,
|
|
87
|
+
)).toBeGreaterThanOrEqual(0);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('discovery: /.well-known/openwop fixtures field shape per RFC 0003', () => {
|
|
93
|
+
it('IF fixtures is present, it MUST be a string[] of unique non-empty entries', async () => {
|
|
94
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
|
|
97
|
+
const body = res.json as { fixtures?: unknown } | undefined;
|
|
98
|
+
const fixtures = body?.fixtures;
|
|
99
|
+
|
|
100
|
+
if (fixtures === undefined) {
|
|
101
|
+
// RFC 0003 makes the field OPTIONAL — pre-RFC hosts and hosts
|
|
102
|
+
// that opt out advertise nothing. Assertion passes trivially.
|
|
103
|
+
expect(fixtures).toBeUndefined();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
expect(Array.isArray(fixtures), driver.describe(
|
|
108
|
+
'capabilities.md §`fixtures` (RFC 0003)',
|
|
109
|
+
'fixtures MUST be an array when present',
|
|
110
|
+
)).toBe(true);
|
|
111
|
+
|
|
112
|
+
const arr = fixtures as unknown[];
|
|
113
|
+
for (const entry of arr) {
|
|
114
|
+
expect(typeof entry, driver.describe(
|
|
115
|
+
'capabilities.md §`fixtures` (RFC 0003)',
|
|
116
|
+
'every fixtures entry MUST be a string',
|
|
117
|
+
)).toBe('string');
|
|
118
|
+
expect((entry as string).length, driver.describe(
|
|
119
|
+
'capabilities.md §`fixtures` (RFC 0003)',
|
|
120
|
+
'every fixtures entry MUST be non-empty',
|
|
121
|
+
)).toBeGreaterThan(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const unique = new Set(arr as string[]);
|
|
125
|
+
expect(unique.size, driver.describe(
|
|
126
|
+
'capabilities.md §`fixtures` (RFC 0003)',
|
|
127
|
+
'fixtures entries SHOULD be unique (consumers MUST tolerate duplicates by deduplicating)',
|
|
128
|
+
)).toBe(arr.length);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('discovery: /v1/openapi.json', () => {
|
|
133
|
+
it('returns 200 with a parseable OpenAPI 3.1 document', async () => {
|
|
134
|
+
const res = await driver.get('/v1/openapi.json', { authenticated: false });
|
|
135
|
+
|
|
136
|
+
expect(res.status, driver.describe(
|
|
137
|
+
'rest-endpoints.md',
|
|
138
|
+
'self-describing OpenAPI endpoint MUST return 200',
|
|
139
|
+
)).toBe(200);
|
|
140
|
+
|
|
141
|
+
const body = res.json as { openapi?: string } | undefined;
|
|
142
|
+
expect(body?.openapi, driver.describe(
|
|
143
|
+
'rest-endpoints.md',
|
|
144
|
+
'response MUST declare openapi >= 3.1',
|
|
145
|
+
)).toMatch(/^3\.[1-9]/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch loop scenarios (Phase 6 / RFC 0007) — exercises `conformance-dispatch-loop`
|
|
3
|
+
* which uses `core.dispatch` to process orchestrator decisions and route to child workflows
|
|
4
|
+
* or terminate the loop.
|
|
5
|
+
*
|
|
6
|
+
* Verifies:
|
|
7
|
+
* 1. Dispatch loop handles `next-worker` by delegating to child run.
|
|
8
|
+
* 2. Dispatch loop handles `terminate` cleanly.
|
|
9
|
+
*
|
|
10
|
+
* Spec references:
|
|
11
|
+
* - RFCS/0007-dispatch-loop.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { driver } from '../lib/driver.js';
|
|
16
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
17
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
18
|
+
|
|
19
|
+
const DISPATCH_LOOP_WORKFLOW_ID = 'conformance-dispatch-loop';
|
|
20
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(DISPATCH_LOOP_WORKFLOW_ID);
|
|
21
|
+
|
|
22
|
+
describe.skipIf(SKIP_NO_FIXTURE)('dispatchLoop: core.dispatch consumes OrchestratorDecision', () => {
|
|
23
|
+
it('host correctly processes orchestrator decisions and terminates', async () => {
|
|
24
|
+
// 1. Create the run
|
|
25
|
+
const create = await driver.post('/v1/runs', { workflowId: DISPATCH_LOOP_WORKFLOW_ID });
|
|
26
|
+
expect(create.status).toBe(201);
|
|
27
|
+
const runId = (create.json as { runId: string }).runId;
|
|
28
|
+
|
|
29
|
+
// Hosts may drive orchestrator decisions internally or through a conformance
|
|
30
|
+
// mock. The black-box contract here is terminal completion.
|
|
31
|
+
|
|
32
|
+
const terminal = await pollUntilTerminal(runId);
|
|
33
|
+
expect(terminal.status, driver.describe(
|
|
34
|
+
'RFCS/0007-dispatch-loop.md §H',
|
|
35
|
+
'run MUST reach terminal `completed` after dispatch processes terminate decision',
|
|
36
|
+
)).toBe('completed');
|
|
37
|
+
|
|
38
|
+
// Verify the event log has the expected orchestrator decisions
|
|
39
|
+
const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
40
|
+
expect(eventsRes.status).toBe(200);
|
|
41
|
+
const events = (eventsRes.json as { events?: any[] })?.events ?? [];
|
|
42
|
+
|
|
43
|
+
const decisions = events
|
|
44
|
+
.filter((e) => e.type === 'runOrchestrator.decided')
|
|
45
|
+
.map((e) => e.payload?.decision?.kind);
|
|
46
|
+
|
|
47
|
+
expect(decisions, driver.describe(
|
|
48
|
+
'RFCS/0007-dispatch-loop.md §D',
|
|
49
|
+
'host MUST emit next-worker then terminate in a loop topology'
|
|
50
|
+
)).toEqual(['next-worker', 'terminate']);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error envelope shape — every non-2xx response MUST share the same
|
|
3
|
+
* `{error, message, details?}` JSON shape. Per rest-endpoints.md.
|
|
4
|
+
*
|
|
5
|
+
* We exercise a few intentional failure paths and verify each error
|
|
6
|
+
* response has the correct envelope.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { driver } from '../lib/driver.js';
|
|
11
|
+
|
|
12
|
+
interface ErrorEnvelope {
|
|
13
|
+
error: unknown;
|
|
14
|
+
message: unknown;
|
|
15
|
+
details?: unknown;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertErrorEnvelope(body: unknown, specSection: string): void {
|
|
20
|
+
expect(typeof body, driver.describe(specSection, 'error response MUST be a JSON object')).toBe(
|
|
21
|
+
'object',
|
|
22
|
+
);
|
|
23
|
+
const env = body as ErrorEnvelope;
|
|
24
|
+
|
|
25
|
+
expect(typeof env.error, driver.describe(
|
|
26
|
+
specSection,
|
|
27
|
+
'error envelope MUST include `error` (machine-readable string)',
|
|
28
|
+
)).toBe('string');
|
|
29
|
+
|
|
30
|
+
expect(typeof env.message, driver.describe(
|
|
31
|
+
specSection,
|
|
32
|
+
'error envelope MUST include `message` (human-readable string)',
|
|
33
|
+
)).toBe('string');
|
|
34
|
+
|
|
35
|
+
if (env.details !== undefined) {
|
|
36
|
+
expect(typeof env.details, driver.describe(
|
|
37
|
+
specSection,
|
|
38
|
+
'error envelope `details` (when present) MUST be a JSON object',
|
|
39
|
+
)).toBe('object');
|
|
40
|
+
expect(env.details, driver.describe(
|
|
41
|
+
specSection,
|
|
42
|
+
'error envelope `details` (when present) MUST NOT be null',
|
|
43
|
+
)).not.toBeNull();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// schemas/error-envelope.schema.json declares `additionalProperties: false`.
|
|
47
|
+
// Top-level body keys MUST be exactly some subset of {error, message, details}.
|
|
48
|
+
// Hosts that emit extras at the top level (correlationId, hint, requestId, etc.)
|
|
49
|
+
// violate the schema. The canonical home for contextual data is `details`.
|
|
50
|
+
const allowedKeys = new Set(['error', 'message', 'details']);
|
|
51
|
+
const extraneousKeys = Object.keys(env as Record<string, unknown>).filter(
|
|
52
|
+
(k) => !allowedKeys.has(k),
|
|
53
|
+
);
|
|
54
|
+
expect(extraneousKeys, driver.describe(
|
|
55
|
+
'schemas/error-envelope.schema.json (additionalProperties:false)',
|
|
56
|
+
`error envelope MUST NOT have keys outside {error, message, details}; extraneous: [${extraneousKeys.join(', ')}]`,
|
|
57
|
+
)).toEqual([]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Assert correlationId convention per spec/v1/rest-endpoints.md §error-envelope.
|
|
62
|
+
* When a host issues a server-side trace ID for a 5xx, it goes under
|
|
63
|
+
* `details.correlationId` (the contextual-data slot), NEVER at the top level.
|
|
64
|
+
* RECOMMENDED, not REQUIRED — hosts that don't emit trace IDs are still
|
|
65
|
+
* conformant. This helper just pins the placement.
|
|
66
|
+
*/
|
|
67
|
+
function assertCorrelationIdShape(body: unknown, specSection: string): void {
|
|
68
|
+
const env = body as ErrorEnvelope;
|
|
69
|
+
// Top-level correlationId would be a spec violation; the assertErrorEnvelope
|
|
70
|
+
// additionalProperties check above catches it. This helper additionally
|
|
71
|
+
// pins the type when present under details.
|
|
72
|
+
if (env.details && typeof env.details === 'object') {
|
|
73
|
+
const det = env.details as Record<string, unknown>;
|
|
74
|
+
if (det.correlationId !== undefined) {
|
|
75
|
+
expect(typeof det.correlationId, driver.describe(
|
|
76
|
+
specSection,
|
|
77
|
+
'when present, details.correlationId MUST be a string',
|
|
78
|
+
)).toBe('string');
|
|
79
|
+
expect((det.correlationId as string).length, driver.describe(
|
|
80
|
+
specSection,
|
|
81
|
+
'details.correlationId MUST be a non-empty string',
|
|
82
|
+
)).toBeGreaterThan(0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('errors: 404 envelope', () => {
|
|
88
|
+
it('GET /v1/runs/{nonexistentId} returns canonical envelope', async () => {
|
|
89
|
+
const res = await driver.get('/v1/runs/openwop-conformance-this-run-id-does-not-exist');
|
|
90
|
+
|
|
91
|
+
expect(
|
|
92
|
+
[403, 404].includes(res.status),
|
|
93
|
+
driver.describe('rest-endpoints.md', 'unknown run MUST return 404 (or 403 if leaking existence is forbidden)'),
|
|
94
|
+
).toBe(true);
|
|
95
|
+
|
|
96
|
+
assertErrorEnvelope(res.json, 'rest-endpoints.md error envelope');
|
|
97
|
+
assertCorrelationIdShape(res.json, 'rest-endpoints.md §error-envelope');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('errors: 400 envelope (validation)', () => {
|
|
102
|
+
it('POST /v1/runs with empty body returns canonical envelope', async () => {
|
|
103
|
+
const res = await driver.post('/v1/runs', {});
|
|
104
|
+
|
|
105
|
+
// 400 is canonical, but some servers may return 422; accept either as
|
|
106
|
+
// long as the envelope is correct. The point of this test is the shape,
|
|
107
|
+
// not the status code.
|
|
108
|
+
expect(
|
|
109
|
+
res.status,
|
|
110
|
+
driver.describe('rest-endpoints.md', 'malformed POST /v1/runs MUST return 4xx'),
|
|
111
|
+
).toBeGreaterThanOrEqual(400);
|
|
112
|
+
expect(res.status).toBeLessThan(500);
|
|
113
|
+
|
|
114
|
+
assertErrorEnvelope(res.json, 'rest-endpoints.md error envelope');
|
|
115
|
+
assertCorrelationIdShape(res.json, 'rest-endpoints.md §error-envelope');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('errors: details.correlationId placement convention', () => {
|
|
120
|
+
it('correlationId (when emitted on 5xx) MUST be in details, never top-level', async () => {
|
|
121
|
+
// We can't easily induce a 5xx from a black-box client, but we CAN
|
|
122
|
+
// verify the structural constraint on every error envelope returned
|
|
123
|
+
// throughout the suite: `correlationId` at the top level is a
|
|
124
|
+
// schema violation per `additionalProperties: false`. The
|
|
125
|
+
// additionalProperties check in assertErrorEnvelope catches that.
|
|
126
|
+
// This test provides a focused recap so the convention is
|
|
127
|
+
// explicitly named in the suite.
|
|
128
|
+
//
|
|
129
|
+
// Hosts MAY omit correlationId entirely (it's RECOMMENDED, not
|
|
130
|
+
// REQUIRED). This scenario passes against any host that conforms.
|
|
131
|
+
const res = await driver.get('/v1/runs/openwop-conformance-correlation-probe-' + Date.now());
|
|
132
|
+
expect(
|
|
133
|
+
[400, 403, 404].includes(res.status),
|
|
134
|
+
driver.describe('rest-endpoints.md', 'unknown run returns 4xx envelope'),
|
|
135
|
+
).toBe(true);
|
|
136
|
+
const body = res.json as Record<string, unknown>;
|
|
137
|
+
expect(body, driver.describe('rest-endpoints.md', 'response MUST be a JSON object')).toBeDefined();
|
|
138
|
+
// The structural constraint: no top-level correlationId.
|
|
139
|
+
expect(body.correlationId, driver.describe(
|
|
140
|
+
'spec/v1/rest-endpoints.md §error-envelope (correlationId convention)',
|
|
141
|
+
'correlationId MUST NOT appear at the top level of the error envelope; canonical home is `details.correlationId`',
|
|
142
|
+
)).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
});
|