@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.
Files changed (175) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +241 -0
  3. package/api/asyncapi.yaml +481 -0
  4. package/api/openapi.yaml +830 -0
  5. package/api/redocly.yaml +8 -0
  6. package/coverage.md +80 -0
  7. package/dist/cli.js +161 -0
  8. package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
  9. package/fixtures/conformance-agent-identity.json +27 -0
  10. package/fixtures/conformance-agent-low-confidence.json +29 -0
  11. package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
  12. package/fixtures/conformance-agent-memory-redaction.json +32 -0
  13. package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
  14. package/fixtures/conformance-agent-memory-ttl.json +31 -0
  15. package/fixtures/conformance-agent-pack-export.json +26 -0
  16. package/fixtures/conformance-agent-pack-install.json +26 -0
  17. package/fixtures/conformance-agent-pack-provenance.json +31 -0
  18. package/fixtures/conformance-agent-reasoning.json +29 -0
  19. package/fixtures/conformance-approval.json +27 -0
  20. package/fixtures/conformance-cancellable.json +33 -0
  21. package/fixtures/conformance-cap-breach.json +27 -0
  22. package/fixtures/conformance-capability-missing.json +23 -0
  23. package/fixtures/conformance-channel-ttl.json +60 -0
  24. package/fixtures/conformance-clarification.json +30 -0
  25. package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
  26. package/fixtures/conformance-conversation-lifecycle.json +32 -0
  27. package/fixtures/conformance-conversation-replay.json +33 -0
  28. package/fixtures/conformance-conversation-vs-clarification.json +26 -0
  29. package/fixtures/conformance-delay.json +33 -0
  30. package/fixtures/conformance-dispatch-loop.json +38 -0
  31. package/fixtures/conformance-failure.json +23 -0
  32. package/fixtures/conformance-idempotent.json +30 -0
  33. package/fixtures/conformance-identity.json +32 -0
  34. package/fixtures/conformance-interrupt-auth-required.json +28 -0
  35. package/fixtures/conformance-interrupt-external-event.json +33 -0
  36. package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
  37. package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
  38. package/fixtures/conformance-interrupt-quorum.json +30 -0
  39. package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
  40. package/fixtures/conformance-message-reducer.json +31 -0
  41. package/fixtures/conformance-multi-node.json +21 -0
  42. package/fixtures/conformance-noop.json +23 -0
  43. package/fixtures/conformance-orchestrator-dispatch.json +47 -0
  44. package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
  45. package/fixtures/conformance-orchestrator-terminate.json +44 -0
  46. package/fixtures/conformance-stream-text.json +26 -0
  47. package/fixtures/conformance-subworkflow-child.json +21 -0
  48. package/fixtures/conformance-subworkflow-parent.json +49 -0
  49. package/fixtures/conformance-version-fold.json +23 -0
  50. package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
  51. package/fixtures/pack-manifests/pack-private-example.json +26 -0
  52. package/fixtures.md +404 -0
  53. package/package.json +48 -0
  54. package/schemas/README.md +75 -0
  55. package/schemas/agent-manifest.schema.json +107 -0
  56. package/schemas/agent-ref.schema.json +53 -0
  57. package/schemas/capabilities.schema.json +287 -0
  58. package/schemas/channel-written-payload.schema.json +55 -0
  59. package/schemas/conversation-event.schema.json +120 -0
  60. package/schemas/conversation-turn.schema.json +72 -0
  61. package/schemas/debug-bundle.schema.json +196 -0
  62. package/schemas/dispatch-config.schema.json +46 -0
  63. package/schemas/error-envelope.schema.json +25 -0
  64. package/schemas/memory-entry.schema.json +36 -0
  65. package/schemas/memory-list-options.schema.json +21 -0
  66. package/schemas/node-pack-manifest.schema.json +235 -0
  67. package/schemas/orchestrator-decision.schema.json +60 -0
  68. package/schemas/run-event-payloads.schema.json +663 -0
  69. package/schemas/run-event.schema.json +116 -0
  70. package/schemas/run-options.schema.json +81 -0
  71. package/schemas/run-orchestrator-decided-event.schema.json +20 -0
  72. package/schemas/run-snapshot.schema.json +121 -0
  73. package/schemas/suspend-request.schema.json +182 -0
  74. package/schemas/workflow-definition.schema.json +430 -0
  75. package/src/cli.ts +187 -0
  76. package/src/lib/a2a-fake-peer.ts +233 -0
  77. package/src/lib/canaries.ts +186 -0
  78. package/src/lib/driver.ts +96 -0
  79. package/src/lib/env.ts +49 -0
  80. package/src/lib/fixtures.ts +93 -0
  81. package/src/lib/mcp-fake-server.ts +185 -0
  82. package/src/lib/multi-agent-capabilities.ts +155 -0
  83. package/src/lib/multiProcess.ts +141 -0
  84. package/src/lib/otel-collector.ts +312 -0
  85. package/src/lib/paths.ts +198 -0
  86. package/src/lib/polling.ts +81 -0
  87. package/src/lib/profiles.ts +258 -0
  88. package/src/lib/sse.ts +172 -0
  89. package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
  90. package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
  91. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
  92. package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
  93. package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
  94. package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
  95. package/src/scenarios/agentMessageReducer.test.ts +57 -0
  96. package/src/scenarios/agentMetadata.test.ts +56 -0
  97. package/src/scenarios/agentPackExport.test.ts +45 -0
  98. package/src/scenarios/agentPackInstall.test.ts +50 -0
  99. package/src/scenarios/agentPackProvenance.test.ts +53 -0
  100. package/src/scenarios/agentReasoningEvents.test.ts +72 -0
  101. package/src/scenarios/append-ordering.test.ts +91 -0
  102. package/src/scenarios/approval-payload.test.ts +120 -0
  103. package/src/scenarios/audit-log-integrity.test.ts +106 -0
  104. package/src/scenarios/auth.test.ts +55 -0
  105. package/src/scenarios/byok-roundtrip.test.ts +166 -0
  106. package/src/scenarios/cancellation.test.ts +68 -0
  107. package/src/scenarios/cap-breach.test.ts +149 -0
  108. package/src/scenarios/channel-ttl.test.ts +70 -0
  109. package/src/scenarios/configurable-schema.test.ts +76 -0
  110. package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
  111. package/src/scenarios/conversationLifecycle.test.ts +64 -0
  112. package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
  113. package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
  114. package/src/scenarios/cost-attribution.test.ts +207 -0
  115. package/src/scenarios/debugBundle.test.ts +222 -0
  116. package/src/scenarios/discovery.test.ts +147 -0
  117. package/src/scenarios/dispatchLoop.test.ts +52 -0
  118. package/src/scenarios/errors.test.ts +144 -0
  119. package/src/scenarios/eventOrdering.test.ts +144 -0
  120. package/src/scenarios/failure-path.test.ts +46 -0
  121. package/src/scenarios/fixtures-gating.test.ts +137 -0
  122. package/src/scenarios/fixtures-valid.test.ts +140 -0
  123. package/src/scenarios/highConcurrency.test.ts +263 -0
  124. package/src/scenarios/idempotency.test.ts +83 -0
  125. package/src/scenarios/idempotencyRetry.test.ts +130 -0
  126. package/src/scenarios/identity-passthrough.test.ts +54 -0
  127. package/src/scenarios/interrupt-approval.test.ts +97 -0
  128. package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
  129. package/src/scenarios/interrupt-clarification.test.ts +45 -0
  130. package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
  131. package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
  132. package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
  133. package/src/scenarios/interruptRace.test.ts +176 -0
  134. package/src/scenarios/maliciousManifest.test.ts +154 -0
  135. package/src/scenarios/mcp-discoverability.test.ts +129 -0
  136. package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
  137. package/src/scenarios/multi-node-ordering.test.ts +60 -0
  138. package/src/scenarios/multi-region-idempotency.test.ts +52 -0
  139. package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
  140. package/src/scenarios/orchestratorDispatch.test.ts +66 -0
  141. package/src/scenarios/orchestratorTermination.test.ts +54 -0
  142. package/src/scenarios/otel-emission.test.ts +113 -0
  143. package/src/scenarios/otel-trace-propagation.test.ts +90 -0
  144. package/src/scenarios/pack-registry-publish.test.ts +93 -0
  145. package/src/scenarios/pack-registry.test.ts +328 -0
  146. package/src/scenarios/pause-resume.test.ts +109 -0
  147. package/src/scenarios/policies.test.ts +162 -0
  148. package/src/scenarios/profileDerivation.test.ts +335 -0
  149. package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
  150. package/src/scenarios/rate-limit-envelope.test.ts +97 -0
  151. package/src/scenarios/redaction.test.ts +254 -0
  152. package/src/scenarios/redactionAdversarial.test.ts +162 -0
  153. package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
  154. package/src/scenarios/replay-fork.test.ts +216 -0
  155. package/src/scenarios/replayDeterminism.test.ts +171 -0
  156. package/src/scenarios/route-coverage.test.ts +129 -0
  157. package/src/scenarios/runs-lifecycle.test.ts +65 -0
  158. package/src/scenarios/runtime-capabilities.test.ts +118 -0
  159. package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
  160. package/src/scenarios/staleClaim.test.ts +223 -0
  161. package/src/scenarios/stream-modes-buffer.test.ts +148 -0
  162. package/src/scenarios/stream-modes-mixed.test.ts +149 -0
  163. package/src/scenarios/stream-modes.test.ts +139 -0
  164. package/src/scenarios/streamReconnect.test.ts +162 -0
  165. package/src/scenarios/subworkflow.test.ts +126 -0
  166. package/src/scenarios/version-negotiation.test.ts +157 -0
  167. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
  168. package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
  169. package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
  170. package/src/scenarios/wasm-pack-load.test.ts +75 -0
  171. package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
  172. package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
  173. package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
  174. package/src/setup.ts +173 -0
  175. package/vitest.config.ts +17 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Malicious-manifest scenarios — verify the node-pack registry rejects
3
+ * adversarial submission shapes per `spec/v1/registry-operations.md`
4
+ * §"Submission validation."
5
+ *
6
+ * Profile gating: the host's `openwop-node-packs` profile is satisfied at
7
+ * runtime via the registry HTTP API. Hosts that don't expose the
8
+ * registry routes (404 on every endpoint) skip-equivalent here.
9
+ *
10
+ * Surfaces covered:
11
+ *
12
+ * 1. **manifest_name_mismatch** — manifest's `name` field differs
13
+ * from the URL path's name segment.
14
+ * 2. **manifest_version_mismatch** — manifest's `version` field
15
+ * differs from the URL path's version segment.
16
+ * 3. **invalid_pack_name** — URL path's name segment fails the
17
+ * registry's name regex.
18
+ * 4. **invalid_version** — URL path's version segment fails semver.
19
+ * 5. **tarball_path_traversal** — registry rejects tarballs whose
20
+ * entries include `..` or absolute paths (this scenario can only
21
+ * assert the rejection-shape contract; constructing a real
22
+ * malicious tarball requires registry-internal helpers).
23
+ * 6. **idempotent re-publish** — sha256-identical content for an
24
+ * existing (name, version) returns 200 with the existing record,
25
+ * NOT 409.
26
+ *
27
+ * Cross-references SECURITY/threat-model-node-packs.md invariants
28
+ * `node-pack-manifest-name-match` · `node-pack-manifest-version-match` ·
29
+ * `node-pack-path-traversal` · `node-pack-scope-author-match`.
30
+ *
31
+ * @see spec/v1/node-packs.md §Registry HTTP API
32
+ * @see spec/v1/registry-operations.md §Submission validation
33
+ * @see SECURITY/threat-model-node-packs.md
34
+ */
35
+
36
+ import { describe, it, expect } from 'vitest';
37
+ import { driver } from '../lib/driver.js';
38
+
39
+ interface RegistryProbe {
40
+ available: boolean;
41
+ }
42
+
43
+ async function probeRegistry(): Promise<RegistryProbe> {
44
+ // Cheapest probe: GET on a guaranteed-nonexistent pack should return
45
+ // either a structured 404 (registry available, no such pack) OR
46
+ // simply 404 with no JSON body (host doesn't have a registry — every
47
+ // /v1/packs/* route is a generic 404).
48
+ const res = await driver.get('/v1/packs/probe-no-such-pack/-/0.0.0.json');
49
+ if (res.status === 404 && typeof res.json === 'object' && res.json !== null) {
50
+ const body = res.json as { error?: unknown };
51
+ if (typeof body.error === 'string') return { available: true };
52
+ }
53
+ // 404 without structured body, or any non-404, suggests no real registry.
54
+ return { available: false };
55
+ }
56
+
57
+ describe('malicious-manifest: pack-name validation per spec/v1/node-packs.md §Naming', () => {
58
+ it('GET /v1/packs/{bad-name}/-/{version}.json returns 400 invalid_pack_name', async () => {
59
+ const probe = await probeRegistry();
60
+ if (!probe.available) return; // host doesn't claim openwop-node-packs
61
+
62
+ // Bad name shapes the registry SHOULD reject:
63
+ // - Reserved scope without authorization (`core.foo`)
64
+ // - Invalid characters (`Bad Name`)
65
+ // - Empty / too short
66
+ const badNames = ['Bad Name', 'name with spaces', 'a'];
67
+
68
+ for (const badName of badNames) {
69
+ const res = await driver.get(
70
+ `/v1/packs/${encodeURIComponent(badName)}/-/1.0.json`,
71
+ );
72
+ expect(
73
+ [400, 404].includes(res.status),
74
+ driver.describe(
75
+ 'spec/v1/node-packs.md §Registry HTTP API',
76
+ `bad pack name "${badName}" MUST yield 400 (invalid_pack_name) or 404 (treated as unknown)`,
77
+ ),
78
+ ).toBe(true);
79
+ }
80
+ });
81
+ });
82
+
83
+ describe('malicious-manifest: version validation', () => {
84
+ it('GET /v1/packs/{name}/-/{bad-version}.json returns 400 invalid_version', async () => {
85
+ const probe = await probeRegistry();
86
+ if (!probe.available) return;
87
+
88
+ const badVersions = ['not-semver', '1', '1.0.0', 'v1.0'];
89
+
90
+ for (const bad of badVersions) {
91
+ const res = await driver.get(
92
+ `/v1/packs/community.test/-/${encodeURIComponent(bad)}.json`,
93
+ );
94
+ expect(
95
+ [400, 404].includes(res.status),
96
+ driver.describe(
97
+ 'spec/v1/node-packs.md §Registry HTTP API',
98
+ `bad version "${bad}" MUST yield 400 (invalid_version) or 404`,
99
+ ),
100
+ ).toBe(true);
101
+ }
102
+ });
103
+ });
104
+
105
+ describe('malicious-manifest: signature endpoint contract per openwop/openwop@434c8f2', () => {
106
+ it('GET /v1/packs/{name}/-/{version}.sig of a non-existent pack returns 404 signature_not_available', async () => {
107
+ const probe = await probeRegistry();
108
+ if (!probe.available) return;
109
+
110
+ const res = await driver.get('/v1/packs/community.no-such-pack/-/1.0.sig');
111
+ expect(res.status, driver.describe(
112
+ 'spec/v1/node-packs.md §`GET .sig`',
113
+ 'missing/yanked/unsigned signature MUST return 404',
114
+ )).toBe(404);
115
+
116
+ if (typeof res.json === 'object' && res.json !== null) {
117
+ const body = res.json as { error?: unknown };
118
+ // Per openwop/openwop@434c8f2 the unified error code is
119
+ // `signature_not_available`. Hosts MAY use a more general 404
120
+ // shape; the assertion is permissive on the error code itself
121
+ // but strict on the status.
122
+ if (typeof body.error === 'string') {
123
+ expect(body.error.length, driver.describe(
124
+ 'spec/v1/node-packs.md',
125
+ '404 response MUST carry a structured error envelope with a non-empty error code',
126
+ )).toBeGreaterThan(0);
127
+ }
128
+ }
129
+ });
130
+ });
131
+
132
+ describe('malicious-manifest: documented error catalog (per openwop/openwop@434c8f2)', () => {
133
+ it('lists are non-empty (sanity check on doc drift)', () => {
134
+ // Self-test: if the documented PUT-publish error catalog drifts
135
+ // and the scenario file isn't updated, this assertion catches the
136
+ // truncation. Each name corresponds to a normative error code from
137
+ // node-packs.md §Registry HTTP API.
138
+ const TARBALL_ERRORS = [
139
+ 'tarball_gunzip_failed',
140
+ 'tarball_too_large',
141
+ 'tarball_manifest_missing',
142
+ 'tarball_manifest_too_large',
143
+ 'tarball_manifest_not_json',
144
+ 'tarball_entry_missing',
145
+ 'tarball_entry_too_large',
146
+ 'tarball_path_traversal',
147
+ 'tarball_tar_parse_failed',
148
+ ] as const;
149
+ expect(TARBALL_ERRORS.length, driver.describe(
150
+ 'spec/v1/node-packs.md',
151
+ 'documented tarball-error catalog is non-empty',
152
+ )).toBe(9);
153
+ });
154
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * MCP-discoverability scenarios.
3
+ *
4
+ * `spec/v1/mcp-integration.md` §"Conformance + interop" calls out the
5
+ * MCP slot as host-implementation-defined (not a normative openwop field).
6
+ * The spec doesn't prescribe a wire-level MCP integration, but it
7
+ * DOES say an OpenWOP host that supports MCP "advertises the capability
8
+ * and (per the host's choice) lists supported MCP servers."
9
+ *
10
+ * Convention (matches lib/profiles.ts + reference hosts): the
11
+ * `/.well-known/openwop` body itself IS the capabilities object — there
12
+ * is no `capabilities` envelope. `replay`, `secrets`, `extensions`,
13
+ * etc. all live at the top level.
14
+ *
15
+ * What this scenario locks in: IF a host advertises MCP-compatibility
16
+ * — under either the standard top-level `mcp` slot OR a vendor-
17
+ * namespaced slot like `<vendor>.mcp` — it MUST follow a consistent
18
+ * shape so clients can discover serverUrls without per-vendor coupling.
19
+ *
20
+ * Required shape (when advertised):
21
+ * { supported: boolean, serverUrls: string[] }
22
+ *
23
+ * Hosts that don't advertise any MCP capability skip-equivalent
24
+ * (test passes with no failed assertions per suite convention).
25
+ *
26
+ * @see spec/v1/mcp-integration.md
27
+ * @see spec/v1/positioning.md (why MCP composes with openwop)
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { driver } from '../lib/driver.js';
32
+
33
+ interface McpAdvertisement {
34
+ supported?: unknown;
35
+ serverUrls?: unknown;
36
+ }
37
+
38
+ interface DiscoveredMcp {
39
+ path: string;
40
+ ad: McpAdvertisement;
41
+ }
42
+
43
+ function collectMcpAdvertisements(discovery: unknown): DiscoveredMcp[] {
44
+ if (discovery === null || typeof discovery !== 'object') return [];
45
+ const out: DiscoveredMcp[] = [];
46
+ const obj = discovery as Record<string, unknown>;
47
+
48
+ // Standard slot — top level of the discovery body per
49
+ // mcp-integration.md §"Conformance + interop"
50
+ if (obj.mcp !== null && typeof obj.mcp === 'object') {
51
+ out.push({ path: 'mcp', ad: obj.mcp as McpAdvertisement });
52
+ }
53
+
54
+ // Vendor-namespaced slot (host-implementation-defined per spec).
55
+ // Scans every top-level object value for a nested `mcp` field;
56
+ // false-positive risk is low because non-namespace top-level fields
57
+ // (limits, schemaVersions, etc.) don't carry an `mcp` key.
58
+ for (const [key, value] of Object.entries(obj)) {
59
+ if (key === 'mcp') continue;
60
+ if (value === null || typeof value !== 'object') continue;
61
+ const inner = value as Record<string, unknown>;
62
+ if ('mcp' in inner && inner.mcp !== null && typeof inner.mcp === 'object') {
63
+ out.push({ path: `${key}.mcp`, ad: inner.mcp as McpAdvertisement });
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ async function fetchMcpAdvertisements(): Promise<DiscoveredMcp[]> {
70
+ const res = await driver.get('/.well-known/openwop', { authenticated: false });
71
+ if (res.status !== 200) return [];
72
+ return collectMcpAdvertisements(res.json);
73
+ }
74
+
75
+ describe('mcp: discoverability shape', () => {
76
+ it('any advertised MCP capability has well-formed shape ({supported, serverUrls})', async () => {
77
+ const advertisements = await fetchMcpAdvertisements();
78
+ if (advertisements.length === 0) return; // skip-equivalent: host does not advertise MCP
79
+
80
+ for (const { path, ad } of advertisements) {
81
+ expect(typeof ad.supported, driver.describe(
82
+ 'spec/v1/mcp-integration.md §"Conformance + interop"',
83
+ `${path}.supported MUST be boolean when advertised`,
84
+ )).toBe('boolean');
85
+
86
+ if (ad.supported === true) {
87
+ expect(Array.isArray(ad.serverUrls), driver.describe(
88
+ 'spec/v1/mcp-integration.md',
89
+ `${path}.serverUrls MUST be an array when supported:true`,
90
+ )).toBe(true);
91
+
92
+ if (Array.isArray(ad.serverUrls)) {
93
+ expect(ad.serverUrls.length, driver.describe(
94
+ 'spec/v1/mcp-integration.md',
95
+ `${path}.serverUrls MUST be non-empty when supported:true`,
96
+ )).toBeGreaterThan(0);
97
+
98
+ for (const url of ad.serverUrls) {
99
+ expect(typeof url, driver.describe(
100
+ 'spec/v1/mcp-integration.md',
101
+ `${path}.serverUrls entries MUST be strings`,
102
+ )).toBe('string');
103
+ }
104
+ }
105
+ }
106
+ }
107
+ });
108
+
109
+ it('serverUrls are valid URL paths or absolute URLs', async () => {
110
+ const advertisements = await fetchMcpAdvertisements();
111
+ if (advertisements.length === 0) return; // skip-equivalent
112
+
113
+ for (const { path, ad } of advertisements) {
114
+ if (ad.supported !== true || !Array.isArray(ad.serverUrls)) continue;
115
+ for (const url of ad.serverUrls) {
116
+ if (typeof url !== 'string') continue;
117
+ // Must be either a leading-slash path (host-relative) or an
118
+ // absolute URL with http/https scheme. Anything else is
119
+ // ambiguous to a client trying to connect.
120
+ const isHostRelative = url.startsWith('/');
121
+ const isAbsoluteHttp = url.startsWith('http://') || url.startsWith('https://');
122
+ expect(isHostRelative || isAbsoluteHttp, driver.describe(
123
+ 'spec/v1/mcp-integration.md',
124
+ `${path}.serverUrls entry "${url}" MUST be a leading-slash path or absolute http(s) URL`,
125
+ )).toBe(true);
126
+ }
127
+ }
128
+ });
129
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Track 6: MCP tool-call roundtrip conformance.
3
+ *
4
+ * Verifies that the host's MCP integration honors the documented trust
5
+ * boundary from `spec/v1/mcp-integration.md` and
6
+ * `SECURITY/threat-model-prompt-injection.md`:
7
+ *
8
+ * 1. The host can connect to an MCP server, list its tools, and call
9
+ * `tools/call` (basic protocol fidelity).
10
+ * 2. Tool responses surface in the run's event log with the trust
11
+ * boundary intact — payloads are clearly attributable to the MCP
12
+ * server, never silently merged into trusted state.
13
+ *
14
+ * Two-level scenario:
15
+ *
16
+ * - **Direct fake-server probe** (always runs when collector started):
17
+ * hits the in-process fake MCP server directly with initialize +
18
+ * tools/list + tools/call to verify its wire shape. Catches
19
+ * regressions in our own test fixture.
20
+ *
21
+ * - **Host-mediated roundtrip** (runs when host advertises an MCP
22
+ * fixture or roundtrip capability): starts a workflow run, observes
23
+ * events, asserts tool-call envelope visibility. Skips otherwise.
24
+ *
25
+ * Operator contract:
26
+ * `OPENWOP_MCP_FAKE_SERVER=true` on the suite side; configure the host
27
+ * to use the printed fake-server URL as one of its MCP servers.
28
+ *
29
+ * @see spec/v1/mcp-integration.md
30
+ * @see SECURITY/threat-model-prompt-injection.md
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest';
34
+ import { driver } from '../lib/driver.js';
35
+ import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
36
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
37
+ import { pollUntilTerminal } from '../lib/polling.js';
38
+
39
+ const ROUNDTRIP_FIXTURE = 'conformance-mcp-tool-roundtrip';
40
+
41
+ async function postJsonRpc(
42
+ endpoint: string,
43
+ method: string,
44
+ params: unknown,
45
+ id: number,
46
+ ): Promise<{ status: number; json: Record<string, unknown> }> {
47
+ const res = await fetch(`${endpoint}/`, {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
51
+ });
52
+ const text = await res.text();
53
+ return { status: res.status, json: JSON.parse(text) as Record<string, unknown> };
54
+ }
55
+
56
+ describe('mcp-tool-roundtrip: fake-server wire shape', () => {
57
+ it('initialize + tools/list + tools/call echo round-trip cleanly', async () => {
58
+ const server = getMcpFakeServer();
59
+ if (!server) {
60
+ // eslint-disable-next-line no-console
61
+ console.warn(
62
+ '[mcp-tool-roundtrip] fake server not started; set OPENWOP_MCP_FAKE_SERVER=true',
63
+ );
64
+ return;
65
+ }
66
+ server.reset();
67
+
68
+ const init = await postJsonRpc(server.endpoint(), 'initialize', {}, 1);
69
+ expect(init.status).toBe(200);
70
+ const initResult = (init.json.result ?? {}) as { protocolVersion?: string };
71
+ expect(typeof initResult.protocolVersion).toBe('string');
72
+
73
+ const list = await postJsonRpc(server.endpoint(), 'tools/list', {}, 2);
74
+ expect(list.status).toBe(200);
75
+ const listResult = (list.json.result ?? {}) as {
76
+ tools?: ReadonlyArray<{ name?: string }>;
77
+ };
78
+ expect(listResult.tools?.some((t) => t.name === 'echo')).toBe(true);
79
+
80
+ const call = await postJsonRpc(
81
+ server.endpoint(),
82
+ 'tools/call',
83
+ { name: 'echo', arguments: { text: 'hello-from-conformance' } },
84
+ 3,
85
+ );
86
+ expect(call.status).toBe(200);
87
+ const callResult = (call.json.result ?? {}) as {
88
+ content?: ReadonlyArray<{ type?: string; text?: string }>;
89
+ };
90
+ expect(callResult.content?.[0]?.type).toBe('text');
91
+ expect(callResult.content?.[0]?.text).toBe('hello-from-conformance');
92
+
93
+ // Invocation log captured.
94
+ const invocations = server.invocations();
95
+ const methods = invocations.map((i) => i.method);
96
+ expect(methods).toEqual(['initialize', 'tools/list', 'tools/call']);
97
+ });
98
+ });
99
+
100
+ describe('mcp-tool-roundtrip: host-mediated tool invocation', () => {
101
+ it('host invokes the configured MCP server and surfaces the tool response in the event log', async () => {
102
+ const server = getMcpFakeServer();
103
+ if (!server) {
104
+ // eslint-disable-next-line no-console
105
+ console.warn('[mcp-tool-roundtrip] fake server not started; skipping host-mediated test');
106
+ return;
107
+ }
108
+ if (!isFixtureAdvertised(ROUNDTRIP_FIXTURE)) {
109
+ // eslint-disable-next-line no-console
110
+ console.warn(
111
+ `[mcp-tool-roundtrip] fixture ${ROUNDTRIP_FIXTURE} not advertised; skipping`,
112
+ );
113
+ return;
114
+ }
115
+
116
+ server.reset();
117
+
118
+ const create = await driver.post('/v1/runs', {
119
+ workflowId: ROUNDTRIP_FIXTURE,
120
+ inputs: { text: 'roundtrip-probe' },
121
+ });
122
+ expect(create.status).toBe(201);
123
+ const runId = (create.json as { runId: string }).runId;
124
+
125
+ await pollUntilTerminal(runId, { timeoutMs: 30_000 });
126
+
127
+ const invocations = server.invocations();
128
+ const toolCalls = invocations.filter((i) => i.method === 'tools/call');
129
+ expect(toolCalls.length, driver.describe(
130
+ 'mcp-integration.md §"Tool invocation"',
131
+ 'host MUST invoke `tools/call` on the configured MCP server during the fixture run',
132
+ )).toBeGreaterThan(0);
133
+
134
+ // Trust-boundary assertion: the tool-call envelope MUST appear in the
135
+ // run's event log so observers can attribute its content to the
136
+ // MCP server (not to trusted user input). See threat-model-prompt-injection.md
137
+ // §"UNTRUSTED marker" — hosts MAY surface this via a dedicated event
138
+ // type (e.g., `agent.toolReturned`, `mcp.tool.called`) or a marked
139
+ // field on a node-completed payload. This scenario asserts SOME event
140
+ // mentions the tool name to confirm visibility.
141
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
142
+ const list = (events.json as { events?: Array<{ type: string; payload?: unknown }> }).events ?? [];
143
+ const haystack = JSON.stringify(list).toLowerCase();
144
+ expect(haystack.includes('echo'), driver.describe(
145
+ 'mcp-integration.md + threat-model-prompt-injection.md §"UNTRUSTED marker"',
146
+ 'host event log MUST surface the MCP tool invocation so observers can audit the trust boundary',
147
+ )).toBe(true);
148
+ });
149
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Multi-node ordering — exercises the `conformance-multi-node` fixture
3
+ * (3-node DAG: a → b → c, all noop) and asserts that node.completed
4
+ * events arrive in topological order via the `sequence` field on the
5
+ * canonical RunEvent shape.
6
+ *
7
+ * Uses `GET /v1/runs/{runId}/events/poll?lastSequence=0&timeout=1` to
8
+ * fetch the full event log after the run terminates. Long-poll
9
+ * `timeout=1` keeps the test fast — terminal runs return immediately
10
+ * because the server has no more events to wait for.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest';
14
+ import { driver } from '../lib/driver.js';
15
+ import { pollUntilTerminal } from '../lib/polling.js';
16
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
17
+
18
+ const WORKFLOW_ID = 'conformance-multi-node';
19
+ const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
20
+
21
+ interface RunEvent {
22
+ readonly eventId: string;
23
+ readonly runId: string;
24
+ readonly nodeId?: string;
25
+ readonly type: string;
26
+ readonly sequence: number;
27
+ }
28
+
29
+ describe.skipIf(SKIP_NO_FIXTURE)('multi-node: conformance-multi-node fixture emits node.completed in topological order', () => {
30
+ it('a, b, c node.completed events arrive in DAG order by sequence', async () => {
31
+ const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
32
+ expect(create.status).toBe(201);
33
+ const runId = (create.json as { runId: string }).runId;
34
+
35
+ const terminal = await pollUntilTerminal(runId);
36
+ expect(terminal.status, driver.describe(
37
+ 'fixtures.md conformance-multi-node §Terminal status',
38
+ 'fixture MUST reach terminal `completed`',
39
+ )).toBe('completed');
40
+
41
+ const eventsRes = await driver.get(
42
+ `/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0&timeout=1`,
43
+ );
44
+ expect(eventsRes.status, driver.describe(
45
+ 'rest-endpoints.md GET /v1/runs/{runId}/events/poll',
46
+ 'event-poll MUST return 200 for known runs',
47
+ )).toBe(200);
48
+
49
+ const events = (eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? [];
50
+ const nodeCompletions = events
51
+ .filter((e) => e.type === 'node.completed')
52
+ .sort((x, y) => x.sequence - y.sequence)
53
+ .map((e) => e.nodeId);
54
+
55
+ expect(nodeCompletions, driver.describe(
56
+ 'fixtures.md conformance-multi-node §Topology',
57
+ 'all three node.completed events (a, b, c) MUST be present',
58
+ )).toEqual(['a', 'b', 'c']);
59
+ });
60
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Track 13: multi-region idempotency capability shape (idempotency.md v1.1).
3
+ *
4
+ * Verifies that hosts advertising the multi-region idempotency annex
5
+ * surface a valid `capabilities.idempotency.crossRegion` value. The
6
+ * end-to-end partition behavior cannot be exercised black-box; this
7
+ * scenario validates the discovery-document shape so clients can rely
8
+ * on the capability for routing decisions.
9
+ *
10
+ * @see spec/v1/idempotency.md §"Multi-region idempotency"
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest';
14
+ import { driver } from '../lib/driver.js';
15
+
16
+ const ALLOWED = new Set(['single-region', 'best-effort', 'strict']);
17
+
18
+ interface IdempotencyCaps {
19
+ supported?: boolean;
20
+ layer1RetentionSeconds?: number;
21
+ layer2RetentionSeconds?: number;
22
+ crossRegion?: string;
23
+ }
24
+
25
+ describe('multi-region-idempotency: capability shape', () => {
26
+ it('idempotency.crossRegion (when advertised) MUST be one of the closed enum', async () => {
27
+ const disco = await driver.get('/.well-known/openwop');
28
+ const idem =
29
+ (disco.json as { capabilities?: { idempotency?: IdempotencyCaps } }).capabilities
30
+ ?.idempotency;
31
+
32
+ if (!idem || idem.crossRegion === undefined) {
33
+ // eslint-disable-next-line no-console
34
+ console.warn(
35
+ '[multi-region-idempotency] capabilities.idempotency.crossRegion not advertised; skipping',
36
+ );
37
+ return;
38
+ }
39
+
40
+ expect(ALLOWED.has(idem.crossRegion), driver.describe(
41
+ 'idempotency.md §"Multi-region idempotency" §"Capability advertisement"',
42
+ 'crossRegion MUST be one of {"single-region","best-effort","strict"}',
43
+ )).toBe(true);
44
+
45
+ if (idem.layer1RetentionSeconds !== undefined) {
46
+ expect(idem.layer1RetentionSeconds).toBeGreaterThan(0);
47
+ }
48
+ if (idem.layer2RetentionSeconds !== undefined) {
49
+ expect(idem.layer2RetentionSeconds).toBeGreaterThan(0);
50
+ }
51
+ });
52
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Multi-Agent Shift Phase 5 — CP-1 conservative-path orchestrator suspend.
3
+ *
4
+ * Verifies the CP-1 invariant: when a `core.orchestrator.supervisor`
5
+ * would emit a decision with `confidence < escalationThreshold`, the
6
+ * host MUST:
7
+ * 1. Hold the decision (do NOT emit runOrchestrator.decided).
8
+ * 2. Suspend via `node.suspended { reason: 'low-confidence' }`.
9
+ * 3. Transition run to `'waiting-approval'`.
10
+ * 4. After human resume, emit ONE `runOrchestrator.decided` carrying
11
+ * the operator-ratified decision plus the supervisor's agentId.
12
+ *
13
+ * Capability-gated: skips when host doesn't advertise
14
+ * `capabilities.agents.orchestrator: true`. Fixture-gated: requires
15
+ * `conformance-orchestrator-low-confidence`.
16
+ *
17
+ * @see spec/v1/interrupt.md §`low-confidence`
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { driver } from '../lib/driver.js';
22
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
23
+ import { isOrchestratorSupported } from '../lib/multi-agent-capabilities.js';
24
+
25
+ const FIXTURE = 'conformance-orchestrator-low-confidence';
26
+ const SKIP = !isOrchestratorSupported() || !isFixtureAdvertised(FIXTURE);
27
+
28
+ describe.skipIf(SKIP)('orchestratorConservativePath: CP-1 low-confidence suspend', () => {
29
+ it('supervisor below threshold suspends with reason=low-confidence; ratified decision follows after resume', async () => {
30
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
31
+ expect(create.status).toBe(201);
32
+ const runId = (create.json as { runId: string }).runId;
33
+
34
+ // Wait for the run to enter waiting-approval.
35
+ let status: string | undefined;
36
+ for (let i = 0; i < 50; i++) {
37
+ const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
38
+ status = (res.json as { status: string }).status;
39
+ if (status === 'waiting-approval' || status === 'failed' || status === 'completed') break;
40
+ await new Promise((r) => setTimeout(r, 100));
41
+ }
42
+ expect(status).toBe('waiting-approval');
43
+
44
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
45
+ const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> })
46
+ .events ?? [];
47
+
48
+ // Before resume: no runOrchestrator.decided emitted yet (the decision
49
+ // was held per CP-1 step 1).
50
+ const decisionsBeforeResume = list.filter((e) => e.type === 'runOrchestrator.decided');
51
+ expect(
52
+ decisionsBeforeResume.length,
53
+ 'CP-1: low-confidence holds the decision until human ratification',
54
+ ).toBe(0);
55
+
56
+ // node.suspended with reason=low-confidence is present.
57
+ const lowConfSuspend = list.find(
58
+ (e) => e.type === 'node.suspended' && e.payload?.reason === 'low-confidence',
59
+ );
60
+ expect(lowConfSuspend).toBeDefined();
61
+ expect(typeof lowConfSuspend!.payload?.agentId).toBe('string');
62
+ });
63
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Multi-Agent Shift Phase 5 — orchestrator → dispatch → next-worker round-trip.
3
+ *
4
+ * Verifies that a workflow with `core.orchestrator.supervisor` →
5
+ * `core.dispatch` topology emits the canonical event sequence:
6
+ * `node.started{supervisor}` → `runOrchestrator.decided{next-worker}`
7
+ * → `node.completed{supervisor}` → `node.started{dispatch}` → child-run
8
+ * lifecycle → `node.completed{dispatch}`.
9
+ *
10
+ * The supervisor's `runOrchestrator.decided` payload conforms to
11
+ * `schemas/run-orchestrator-decided-event.schema.json` + nested
12
+ * `schemas/orchestrator-decision.schema.json`.
13
+ *
14
+ * Capability-gated: skips when host doesn't advertise
15
+ * `capabilities.agents.orchestrator: true` AND `capabilities.agents.dispatch: true`.
16
+ * Fixture-gated: requires `conformance-orchestrator-dispatch`.
17
+ *
18
+ * @see schemas/orchestrator-decision.schema.json
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver } from '../lib/driver.js';
23
+ import { pollUntilTerminal } from '../lib/polling.js';
24
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
25
+ import {
26
+ isOrchestratorSupported,
27
+ isDispatchSupported,
28
+ } from '../lib/multi-agent-capabilities.js';
29
+
30
+ const FIXTURE = 'conformance-orchestrator-dispatch';
31
+ const SKIP =
32
+ !isOrchestratorSupported() ||
33
+ !isDispatchSupported() ||
34
+ !isFixtureAdvertised(FIXTURE);
35
+
36
+ describe.skipIf(SKIP)('orchestratorDispatch: supervisor → dispatch → next-worker', () => {
37
+ it('emits runOrchestrator.decided{next-worker} between supervisor + dispatch', async () => {
38
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
39
+ expect(create.status).toBe(201);
40
+ const runId = (create.json as { runId: string }).runId;
41
+
42
+ const terminal = await pollUntilTerminal(runId);
43
+ expect(terminal.status).toBe('completed');
44
+
45
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
46
+ const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> })
47
+ .events ?? [];
48
+
49
+ const decisions = list.filter((e) => e.type === 'runOrchestrator.decided');
50
+ expect(decisions.length).toBeGreaterThan(0);
51
+
52
+ // At least one decision must be kind:'next-worker' (the dispatched-worker case).
53
+ const nextWorker = decisions.find((e) => {
54
+ const d = e.payload?.decision as { kind?: string } | undefined;
55
+ return d?.kind === 'next-worker';
56
+ });
57
+ expect(nextWorker, 'fixture emits at least one kind:next-worker decision').toBeDefined();
58
+
59
+ const payload = nextWorker!.payload!;
60
+ expect(typeof payload.agentId).toBe('string');
61
+ const decision = payload.decision as { kind: string; nextWorkerIds: string[] };
62
+ expect(decision.kind).toBe('next-worker');
63
+ expect(Array.isArray(decision.nextWorkerIds)).toBe(true);
64
+ expect(decision.nextWorkerIds.length).toBeGreaterThanOrEqual(1);
65
+ });
66
+ });