@openwop/openwop-conformance 2.35.1 → 2.36.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 +20 -0
- package/README.md +3 -3
- package/coverage.md +13 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
- package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
- package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
- package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
- package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
- package/fixtures/a2ui-v09/negative-image-component.json +145 -0
- package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
- package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
- package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
- package/fixtures/conformance-artifact-emit.json +28 -0
- package/fixtures/conformance-clarification-nested.json +53 -0
- package/fixtures/conformance-clarification-sensitive.json +49 -0
- package/fixtures/conformance-credential.json +25 -0
- package/fixtures/conformance-mcp-client.json +28 -0
- package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
- package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
- package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
- package/fixtures/node-pack-runtime/negative-headers.json +37 -0
- package/fixtures/node-pack-runtime/negative-http.json +31 -0
- package/fixtures/node-pack-runtime/negative-icons.json +37 -0
- package/fixtures/node-pack-runtime/negative-meta.json +36 -0
- package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
- package/fixtures/node-pack-runtime/negative-packages.json +41 -0
- package/fixtures/node-pack-runtime/negative-sse.json +31 -0
- package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
- package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
- package/fixtures/node-pack-runtime/negative-variables.json +38 -0
- package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
- package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
- package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
- package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
- package/fixtures/oauth-providers/synthetic.json +36 -4
- package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
- package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
- package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
- package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
- package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
- package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
- package/fixtures.md +139 -0
- package/package.json +2 -2
- package/requirements.json +4480 -97
- package/scenario-majors.json +93 -6
- package/schemas/CORPUS-STAMP.json +103 -89
- package/src/lib/localized-content.ts +53 -0
- package/src/lib/mcp-fake-server.ts +105 -4
- package/src/lib/node-pack-runtime.ts +41 -0
- package/src/lib/oauth-as-double.ts +316 -0
- package/src/lib/protected-resource.ts +88 -0
- package/src/lib/standard-webhooks.ts +120 -0
- package/src/lib/toolCatalog.ts +54 -4
- package/src/lib/trace-context.ts +98 -0
- package/src/lib/v2.ts +6 -0
- package/src/lib/webhook-receiver.ts +201 -1
- package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
- package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
- package/src/scenarios/debugBundle.test.ts +41 -0
- package/src/scenarios/fixtures-valid.test.ts +72 -0
- package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
- package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
- package/src/scenarios/localized-content-delivery.test.ts +26 -15
- package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
- package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
- package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
- package/src/scenarios/tool-catalog-projection.test.ts +48 -12
- package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
- package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
- package/src/scenarios/v2-auth-challenge.test.ts +119 -0
- package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
- package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
- package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
- package/src/scenarios/v2-configurable-closed.test.ts +1 -1
- package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
- package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
- package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
- package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
- package/src/scenarios/v2-implementation-informational.test.ts +33 -0
- package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
- package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
- package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
- package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
- package/src/scenarios/v2-run-cancel.test.ts +1 -1
- package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
- package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
- package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
- package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
- package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0200 §B.3 — a challenge never becomes an existence oracle (suite 2.36.0, both
|
|
3
|
+
* majors; invariant `auth-challenge-no-oracle`).
|
|
4
|
+
*
|
|
5
|
+
* This is the positive control for the §B challenge rules: the thing a host is TEMPTED to
|
|
6
|
+
* do once it starts answering "you lack `tools:read`" is answer it everywhere, including
|
|
7
|
+
* on the routes OpenWOP requires to be a non-disclosing `404` for an unknown **or
|
|
8
|
+
* unauthorized** resource. RFC 6750 §3 and MCP's §"Runtime Insufficient Scope Errors" say
|
|
9
|
+
* nothing about existence oracles; OpenWOP's rule is stronger and wins.
|
|
10
|
+
*
|
|
11
|
+
* The routes that carry the identical-404 rule:
|
|
12
|
+
* - `tool-catalog.md` §`GET /v1/tools/{toolId}` (v2: `spec/v2/core/tool-catalog.md`)
|
|
13
|
+
* - the RFC 0074 / 0072 / 0086 / 0087 agent inventory (`api/openapi.yaml` getAgent:
|
|
14
|
+
* "404s identically to 'not installed'")
|
|
15
|
+
* - `capabilities-change-detection.md` ("Hosts MUST NOT let scoped discovery become an
|
|
16
|
+
* authorization oracle")
|
|
17
|
+
*
|
|
18
|
+
* One requirement id, three legs that share it — every leg is an instance of the same
|
|
19
|
+
* MUST, and a host that leaks on any of them has the oracle:
|
|
20
|
+
* 1. `GET <tools>/<unknown>` → `404`, and no `WWW-Authenticate` carrying
|
|
21
|
+
* `insufficient_scope` or `scope`.
|
|
22
|
+
* 2. `GET <agents>/<unknown>` → the same.
|
|
23
|
+
* 3. with `OPENWOP_TEST_TENANT_B_API_KEY`, a cross-tenant run read → the refusal
|
|
24
|
+
* carries no `insufficient_scope`, because no scope would cure a resource-binding
|
|
25
|
+
* refusal.
|
|
26
|
+
*
|
|
27
|
+
* Each leg carries its own positive control: leg 1 and 2 assert the route is LIVE for the
|
|
28
|
+
* same credential (the collection read answers 200), so a `404` from "this host serves no
|
|
29
|
+
* catalog at all" cannot be mistaken for the silent non-disclosure under test. Leg 3
|
|
30
|
+
* asserts the owner can read the run it then reads cross-tenant.
|
|
31
|
+
*
|
|
32
|
+
* How it FAILS (the sabotage run before citing the row):
|
|
33
|
+
* (a) the host 403s an unauthorized tool with a scope challenge → leg 1
|
|
34
|
+
* (b) the host attaches `insufficient_scope` to EVERY 403 → leg 3
|
|
35
|
+
* (c) the host turns the unknown-id 404 into a 401 challenge → legs 1, 2
|
|
36
|
+
*
|
|
37
|
+
* @see spec/v2/core/identity.md §2.5; spec/v1/auth.md §Challenges
|
|
38
|
+
* @see RFCS/0200-host-as-oauth-protected-resource.md §B.3
|
|
39
|
+
* @see SECURITY/invariants.yaml id: auth-challenge-no-oracle
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { describe, it, expect } from 'vitest';
|
|
43
|
+
import { randomBytes } from 'node:crypto';
|
|
44
|
+
import { driver } from '../lib/driver.js';
|
|
45
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
46
|
+
import { req } from '../lib/requirement-ids.js';
|
|
47
|
+
import { targetMajor } from '../lib/seams.js';
|
|
48
|
+
import { toolCatalogGate, toolsPath } from '../lib/toolCatalog.js';
|
|
49
|
+
import { parseChallenge } from '../lib/protected-resource.js';
|
|
50
|
+
import { projectBoundId } from '../lib/bound-id.js';
|
|
51
|
+
|
|
52
|
+
const DOC = 'spec/v1/auth.md §Challenges; spec/v2/core/identity.md §2.5 (RFC 0200 §B.3)';
|
|
53
|
+
const ID = 'openwop.requirement.0200.no-challenge-on-nondisclosure-404';
|
|
54
|
+
|
|
55
|
+
const agentsPath = (suffix = ''): string => `${targetMajor() === 2 ? '' : '/v1'}/agents${suffix}`;
|
|
56
|
+
const runsPath = (suffix = ''): string => `${targetMajor() === 2 ? '' : '/v1'}/runs${suffix}`;
|
|
57
|
+
|
|
58
|
+
/** The scope-disclosing part of a challenge, or null — this is the whole measurement. */
|
|
59
|
+
function scopeDisclosure(raw: string | null): string | null {
|
|
60
|
+
const c = parseChallenge(raw);
|
|
61
|
+
if (c === null) return null;
|
|
62
|
+
if (c.params['error'] === 'insufficient_scope') return `error="insufficient_scope"`;
|
|
63
|
+
if (typeof c.params['scope'] === 'string') return `scope="${c.params['scope']}"`;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('RFC 0200 §B.3 — auth-challenge-no-oracle (a challenge never replaces a required 404)', () => {
|
|
68
|
+
it('an unknown tool id stays a silent 404 — no scope challenge, no status change', async () => {
|
|
69
|
+
if (!(await toolCatalogGate('openwop-tool-catalog'))) return;
|
|
70
|
+
// Positive control: the catalog is LIVE for this credential, so the 404 below is the
|
|
71
|
+
// non-disclosure rule and not "this host serves no catalog".
|
|
72
|
+
const list = await driver.get(toolsPath());
|
|
73
|
+
if (list.status !== 200) return softSkip('blocked', `the catalog collection answered ${list.status}, so an unknown-id 404 would prove nothing about non-disclosure`);
|
|
74
|
+
const unknown = `conformance.absent-${randomBytes(8).toString('hex')}`;
|
|
75
|
+
const res = await driver.get(toolsPath(`/${encodeURIComponent(unknown)}`));
|
|
76
|
+
expect(
|
|
77
|
+
res.status,
|
|
78
|
+
req(ID, `${DOC}; spec/v1/tool-catalog.md §GET /v1/tools/{toolId}`, `an unknown or unauthorized tool id MUST answer 404 — a challenge attaches only to a response that is ALREADY 401/403 and MUST NOT change a status (got ${res.status})`),
|
|
79
|
+
).toBe(404);
|
|
80
|
+
expect(
|
|
81
|
+
scopeDisclosure(res.headers.get('www-authenticate')),
|
|
82
|
+
req(ID, DOC, `the 404 MUST NOT carry error="insufficient_scope" or a scope parameter — that is what turns the catalog into an enumeration oracle (WWW-Authenticate: ${res.headers.get('www-authenticate') ?? 'absent'})`),
|
|
83
|
+
).toBeNull();
|
|
84
|
+
}, 30_000);
|
|
85
|
+
|
|
86
|
+
it('an unknown agent id stays a silent 404 — no scope challenge, no status change', async () => {
|
|
87
|
+
const list = await driver.get(agentsPath());
|
|
88
|
+
if (list.status !== 200) return softSkip('inapplicable', `the agent inventory answered ${list.status} — this host serves no RFC 0074 inventory, so its identical-404 rule does not bind it`);
|
|
89
|
+
const unknown = `absent-agent-${randomBytes(8).toString('hex')}`;
|
|
90
|
+
const res = await driver.get(agentsPath(`/${encodeURIComponent(unknown)}`));
|
|
91
|
+
expect(
|
|
92
|
+
res.status,
|
|
93
|
+
req(ID, `${DOC}; api/openapi.yaml getAgent (RFC 0074)`, `an unknown or unauthorized agent id MUST answer 404 identically to "not installed" (got ${res.status})`),
|
|
94
|
+
).toBe(404);
|
|
95
|
+
expect(
|
|
96
|
+
scopeDisclosure(res.headers.get('www-authenticate')),
|
|
97
|
+
req(ID, DOC, `the inventory 404 MUST NOT carry a scope challenge (WWW-Authenticate: ${res.headers.get('www-authenticate') ?? 'absent'})`),
|
|
98
|
+
).toBeNull();
|
|
99
|
+
}, 30_000);
|
|
100
|
+
|
|
101
|
+
it('a resource-binding refusal carries no insufficient_scope, because no scope would cure it', async () => {
|
|
102
|
+
const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
|
|
103
|
+
if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY is not set — a resource-binding refusal needs a SECOND principal, and the suite will not fabricate one');
|
|
104
|
+
const created = await driver.post(runsPath(), { workflowId: 'conformance-noop' });
|
|
105
|
+
if (created.status !== 201) return softSkip('blocked', `could not create a run to read cross-tenant (POST ${runsPath()} answered ${created.status})`);
|
|
106
|
+
const runId = String((created.json as { runId?: unknown } | null)?.runId ?? '');
|
|
107
|
+
if (runId === '') return softSkip('blocked', 'the create response carried no runId');
|
|
108
|
+
// Positive control: the OWNER can read it, so the refusal below is about the second
|
|
109
|
+
// principal's binding and not about a run that does not exist.
|
|
110
|
+
const owner = await driver.get(runsPath(`/${projectBoundId(runId)}`));
|
|
111
|
+
if (owner.status !== 200) return softSkip('blocked', `the owner could not read its own run (${owner.status}) — the cross-tenant read would prove nothing`);
|
|
112
|
+
const res = await driver.get(runsPath(`/${projectBoundId(runId)}`), { authenticated: false, headers: { Authorization: `Bearer ${other}` } });
|
|
113
|
+
expect(
|
|
114
|
+
res.status === 403 || res.status === 404,
|
|
115
|
+
req(ID, DOC, `a second principal's read of another tenant's run MUST be refused 403 (run_forbidden / id_tenant_mismatch) or 404, never served (got ${res.status})`),
|
|
116
|
+
).toBe(true);
|
|
117
|
+
expect(
|
|
118
|
+
scopeDisclosure(res.headers.get('www-authenticate')),
|
|
119
|
+
req(ID, DOC, `a refusal that failed RESOURCE binding MUST NOT carry error="insufficient_scope" or a scope parameter — no scope would cure it, and naming one tells the caller the resource exists (WWW-Authenticate: ${res.headers.get('www-authenticate') ?? 'absent'})`),
|
|
120
|
+
).toBeNull();
|
|
121
|
+
}, 60_000);
|
|
122
|
+
});
|
|
@@ -155,7 +155,17 @@ describe('auth-oauth2-client-credentials: malformed JWT rejected', () => {
|
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
describe('auth-oauth2-client-credentials: harness-minted negative cases', () => {
|
|
158
|
-
|
|
158
|
+
/**
|
|
159
|
+
* RFC 0200 §C — before this RFC, `auth-profiles.md` said only that a wrong-audience
|
|
160
|
+
* token "uses the canonical error envelope"; the REJECTION itself was assumed by this
|
|
161
|
+
* very assertion and by the schema description of `capabilities.auth.oauth2.audience`,
|
|
162
|
+
* and stated nowhere. The profile now carries it as a MUST.
|
|
163
|
+
*
|
|
164
|
+
* Major-1 only and NON-GATING: `check-accepted-predicate` rule 4 reads only certified
|
|
165
|
+
* v2 bundles and no v2 cut reaches this file, so this id carries no rule-4 weight. v2
|
|
166
|
+
* needs nothing here — `identity.md` §2.1 already requires every lane to check audience.
|
|
167
|
+
*/
|
|
168
|
+
it('a wrong-audience access token is rejected 401 unauthenticated (RFC 0200 §C)', async () => {
|
|
159
169
|
const auth = await readAuthCaps();
|
|
160
170
|
|
|
161
171
|
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
@@ -179,7 +189,6 @@ describe('auth-oauth2-client-credentials: harness-minted negative cases', () =>
|
|
|
179
189
|
algorithm: 'RS256',
|
|
180
190
|
});
|
|
181
191
|
|
|
182
|
-
// Wrong audience.
|
|
183
192
|
const wrongAud = issuer.mint({ aud: 'wrong-audience', sub: 'conformance-suite' });
|
|
184
193
|
const wrongAudRes = await driver.post(
|
|
185
194
|
'/v1/runs',
|
|
@@ -189,10 +198,42 @@ describe('auth-oauth2-client-credentials: harness-minted negative cases', () =>
|
|
|
189
198
|
headers: { Authorization: `Bearer ${wrongAud.token}` },
|
|
190
199
|
},
|
|
191
200
|
);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
201
|
+
// RFC 0200 §C made the obligation explicit: before this, `auth-profiles.md` said only
|
|
202
|
+
// that a wrong-audience token "uses the canonical error envelope", and the rejection
|
|
203
|
+
// itself was assumed by this very assertion and by the schema description of
|
|
204
|
+
// `capabilities.auth.oauth2.audience` — never stated. The profile now says the host
|
|
205
|
+
// MUST reject such a token 401 unauthenticated, before any authorization decision.
|
|
206
|
+
// Major-1 only and NON-GATING: v2 needs nothing here, because `identity.md` §2.1
|
|
207
|
+
// already requires every lane to check audience, and no v2 cut reaches this file.
|
|
208
|
+
expect(wrongAudRes.status, req('openwop.requirement.0200.oauth2cc-wrong-aud-401',
|
|
209
|
+
'spec/v1/auth-profiles.md §`openwop-auth-oauth2-client-credentials` (RFC 0200 §C.1)',
|
|
210
|
+
'an access token whose `aud` does not contain the advertised capabilities.auth.oauth2.audience MUST be rejected 401 unauthenticated, before any authorization decision',
|
|
195
211
|
)).toBe(401);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('wrong-audience token returns 401 when host trusts the harness', async () => {
|
|
215
|
+
const auth = await readAuthCaps();
|
|
216
|
+
|
|
217
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (process.env.OPENWOP_TEST_OAUTH_ISSUER_TRUSTED !== 'true') {
|
|
222
|
+
// eslint-disable-next-line no-console
|
|
223
|
+
console.warn(
|
|
224
|
+
'[auth-oauth2-client-credentials] OPENWOP_TEST_OAUTH_ISSUER_TRUSTED not set; skipping harness-minted negative cases (operator must pre-configure the host to trust the conformance harness)',
|
|
225
|
+
);
|
|
226
|
+
return softSkip('blocked', 'precondition not met — `process.env.OPENWOP_TEST_OAUTH_ISSUER_TRUSTED !== \'true\'` returned early ([auth-oauth2-client-credentials] OPENWOP_TEST_OAUTH_ISSUER_TRUSTED not set; skipping harness-minted negative cases (ope…');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const issuerUrl =
|
|
230
|
+
process.env.OPENWOP_TEST_OAUTH_ISSUER_URL ?? 'http://127.0.0.1:0/oauth';
|
|
231
|
+
const audience = auth?.oauth2?.audience ?? 'openwop-conformance';
|
|
232
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
233
|
+
issuer: issuerUrl,
|
|
234
|
+
audience,
|
|
235
|
+
algorithm: 'RS256',
|
|
236
|
+
});
|
|
196
237
|
|
|
197
238
|
// Expired token.
|
|
198
239
|
const expired = issuer.mint(
|
|
@@ -1,71 +1,105 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cross-host-traceparent-propagation — RFC 0040 §B
|
|
2
|
+
* cross-host-traceparent-propagation — RFC 0040 §B, carriers named by RFC 0207
|
|
3
|
+
* (major 1; supplementary, non-gating — the gating home of the same ids is
|
|
4
|
+
* `v2-interop-trace-context.test.ts`).
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
* `
|
|
6
|
+
* `spec/v1/multi-agent-execution.md` §"W3C tracecontext across MCP + A2A
|
|
7
|
+
* composition": a host that dispatches MCP tool calls AND advertises
|
|
8
|
+
* `multiAgent.executionModel.version >= 3` MUST inject the parent run's W3C
|
|
9
|
+
* trace context into every outbound MCP request — in `params._meta.traceparent`
|
|
10
|
+
* or, on Streamable HTTP, in the HTTP `traceparent` header (SHOULD `_meta`) —
|
|
11
|
+
* and outbound A2A messages MUST carry it in
|
|
12
|
+
* `Message.metadata.openwop.traceparent` or the HTTP header (SHOULD the
|
|
13
|
+
* metadata).
|
|
8
14
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
15
|
+
* Until suite 2.36.0 this file was two `it.skip` placeholders that read the
|
|
16
|
+
* carrier as an HTTP header only and waited on a peer harness. The harness is
|
|
17
|
+
* the suite's own fake MCP server and fake A2A peer, which record every
|
|
18
|
+
* request's params, body and headers; the run is started with the suite's own
|
|
19
|
+
* `traceparent` (a fresh trace id) on `POST /v1/runs`, so the request the host
|
|
20
|
+
* sends the fake is where the carrier is read. The verdict is the D4 rule
|
|
21
|
+
* (`../lib/trace-context.ts`): neither carrier ⇒ fail; a different trace id ⇒
|
|
22
|
+
* fail; the header alone ⇒ PASS (logged `header-only`).
|
|
11
23
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* RFC 0023's same-host coverage left open).
|
|
17
|
-
*
|
|
18
|
-
* 2. An inbound MCP tool reply OR A2A message handler MUST adopt the
|
|
19
|
-
* `traceparent` header from the inbound envelope as the trace
|
|
20
|
-
* parent for any subsequent events the receiving agent emits.
|
|
21
|
-
*
|
|
22
|
-
* 3. (Symmetric) Outbound A2A messages MUST carry the parent run's
|
|
23
|
-
* `traceparent`; inbound A2A handlers MUST adopt it.
|
|
24
|
-
*
|
|
25
|
-
* Behavioral wiring requires a cross-host test harness: either a real
|
|
26
|
-
* MCP server peer (`OPENWOP_MCP_REAL_SERVER_URL`) or an A2A peer
|
|
27
|
-
* (`OPENWOP_A2A_REAL_PEER_URL`) the host can call into. Without those,
|
|
28
|
-
* the assertion soft-skips and only the shape probe in
|
|
29
|
-
* cross-host-causation-shape.test.ts applies.
|
|
24
|
+
* Gates: `version >= 3`; the fixture (`conformance-mcp-tool-roundtrip` /
|
|
25
|
+
* `conformance-a2a-task-roundtrip`) advertised; the fake server / peer started
|
|
26
|
+
* (`OPENWOP_MCP_FAKE_SERVER=true` / `OPENWOP_A2A_FAKE_PEER=true`), else
|
|
27
|
+
* `blocked`, never a pass.
|
|
30
28
|
*
|
|
31
29
|
* @see RFCS/0040-multi-agent-cross-host-causation.md §B
|
|
30
|
+
* @see RFCS/0207-trace-context-across-mcp-and-a2a.md §A, §B
|
|
32
31
|
* @see spec/v1/multi-agent-execution.md §"W3C tracecontext across MCP + A2A composition"
|
|
33
|
-
* @see RFCS/0023-conformance-agent-event-emitters.md (the same-host predecessor)
|
|
34
32
|
*/
|
|
35
33
|
|
|
36
|
-
import { describe, it } from 'vitest';
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { driver } from '../lib/driver.js';
|
|
36
|
+
import { discoveryFamilies } from '../lib/discovery-capabilities.js';
|
|
37
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
38
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
39
|
+
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
40
|
+
import { getA2AFakePeer } from '../lib/a2a-fake-peer.js';
|
|
41
|
+
import { makeTraceparent, classifyCarriers, mcpMetaTraceparent, a2aMetadataTraceparent } from '../lib/trace-context.js';
|
|
42
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
43
|
+
import { req } from '../lib/requirement-ids.js';
|
|
44
|
+
|
|
45
|
+
export const REQUIRES_HOST_CALLBACK = "the host's MCP / A2A client calls the suite's fake MCP server / fake A2A peer";
|
|
46
|
+
|
|
47
|
+
const DOC = 'multi-agent-execution.md §"W3C tracecontext across MCP + A2A composition" (RFC 0040 §B, RFC 0207)';
|
|
48
|
+
const MCP_FIXTURE = 'conformance-mcp-tool-roundtrip';
|
|
49
|
+
const A2A_FIXTURE = 'conformance-a2a-task-roundtrip';
|
|
50
|
+
|
|
51
|
+
async function executionModelVersion(): Promise<number> {
|
|
52
|
+
try {
|
|
53
|
+
const res = await driver.get('/.well-known/openwop');
|
|
54
|
+
const caps = discoveryFamilies(res.json) as { multiAgent?: { executionModel?: { version?: unknown } } };
|
|
55
|
+
const v = caps.multiAgent?.executionModel?.version;
|
|
56
|
+
return typeof v === 'number' ? v : 0;
|
|
57
|
+
} catch {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MCP_CARRIED = 'openwop.requirement.0207.mcp-traceparent-carried';
|
|
63
|
+
const A2A_CARRIED = 'openwop.requirement.0207.a2a-traceparent-carried';
|
|
37
64
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
function carried(leg: string, inMessage: unknown, header: string | undefined, traceId: string, inMessageName: string): { ok: boolean; message: string } {
|
|
66
|
+
const v = classifyCarriers(inMessage, header, traceId, { inMessage: inMessageName });
|
|
67
|
+
// eslint-disable-next-line no-console
|
|
68
|
+
console.info(`[cross-host-traceparent-propagation] ${leg}: carrier ${v.ok ? v.detail : 'none'}`);
|
|
69
|
+
return { ok: v.ok, message: v.ok ? `the outbound request carries the parent run's trace (carrier: ${v.detail})` : v.reason };
|
|
70
|
+
}
|
|
44
71
|
|
|
45
|
-
describe('cross-host-traceparent-propagation
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// requires the MCP peer harness (OPENWOP_MCP_REAL_SERVER_URL) +
|
|
62
|
-
// inbound-header recorder; flips to a real `it()` on first non-steward
|
|
63
|
-
// Phase 3 host advertising matching capabilities.
|
|
64
|
-
it.skip('Phase 3 host MUST inject parent run\'s traceparent into outbound MCP requests — out of stable profile via RFC 0042');
|
|
72
|
+
describe('cross-host-traceparent-propagation (RFC 0040 §B, RFC 0207 carriers)', () => {
|
|
73
|
+
it('a version >= 3 host carries the parent run\'s trace into its outbound MCP tools/call', async () => {
|
|
74
|
+
if ((await executionModelVersion()) < 3) return softSkip('inapplicable', 'multiAgent.executionModel.version < 3 — RFC 0040 §B binds only Phase 3 hosts');
|
|
75
|
+
if (!isFixtureAdvertised(MCP_FIXTURE)) return softSkip('inapplicable', `fixture ${MCP_FIXTURE} not advertised — the host does not consume MCP through the conformance node`);
|
|
76
|
+
const server = getMcpFakeServer();
|
|
77
|
+
if (!server) return softSkip('blocked', 'the suite fake MCP server is not started (OPENWOP_MCP_FAKE_SERVER=true)');
|
|
78
|
+
server.reset();
|
|
79
|
+
const tp = makeTraceparent();
|
|
80
|
+
const create = await driver.post('/v1/runs', { workflowId: MCP_FIXTURE, inputs: { text: 'traceparent-probe' } }, { headers: { traceparent: tp.header } });
|
|
81
|
+
if (create.status !== 201) return softSkip('blocked', `POST /v1/runs answered ${create.status} — the fixture run was refused`);
|
|
82
|
+
await pollUntilTerminal((create.json as { runId: string }).runId, { timeoutMs: 30_000 });
|
|
83
|
+
const call = server.invocations().find((i) => i.method === 'tools/call');
|
|
84
|
+
if (!call) return softSkip('blocked', 'the fixture run made no tools/call to the suite fake server — the host\'s MCP binding does not reach it');
|
|
85
|
+
const v = carried('tools/call', mcpMetaTraceparent(call.params), call.headers['traceparent'], tp.traceId, 'params._meta.traceparent');
|
|
86
|
+
expect(v.ok, req(MCP_CARRIED, DOC, v.message)).toBe(true);
|
|
87
|
+
});
|
|
65
88
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
it('a version >= 3 host carries the parent run\'s trace into its outbound A2A message', async () => {
|
|
90
|
+
if ((await executionModelVersion()) < 3) return softSkip('inapplicable', 'multiAgent.executionModel.version < 3 — RFC 0040 §B binds only Phase 3 hosts');
|
|
91
|
+
if (!isFixtureAdvertised(A2A_FIXTURE)) return softSkip('inapplicable', `fixture ${A2A_FIXTURE} not advertised — the host does not consume A2A peers`);
|
|
92
|
+
const peer = getA2AFakePeer();
|
|
93
|
+
if (!peer) return softSkip('blocked', 'the suite fake A2A peer is not started (OPENWOP_A2A_FAKE_PEER=true)');
|
|
94
|
+
peer.reset();
|
|
95
|
+
peer.setNextState('REJECTED');
|
|
96
|
+
const tp = makeTraceparent();
|
|
97
|
+
const create = await driver.post('/v1/runs', { workflowId: A2A_FIXTURE, inputs: { driftScenario: 'rejected' } }, { headers: { traceparent: tp.header } });
|
|
98
|
+
if (create.status !== 201) return softSkip('blocked', `POST /v1/runs answered ${create.status} — the fixture run was refused`);
|
|
99
|
+
await pollUntilTerminal((create.json as { runId: string }).runId, { timeoutMs: 15_000 });
|
|
100
|
+
const sent = peer.invocations().find((i) => i.rpcMethod === 'SendMessage' || i.rpcMethod === 'message/send');
|
|
101
|
+
if (!sent) return softSkip('blocked', 'the fixture run sent no message to the suite fake peer — the host\'s A2A binding does not reach it');
|
|
102
|
+
const v = carried('SendMessage', a2aMetadataTraceparent(sent.body), sent.headers['traceparent'], tp.traceId, 'params.message.metadata.openwop.traceparent');
|
|
103
|
+
expect(v.ok, req(A2A_CARRIED, DOC, v.message)).toBe(true);
|
|
104
|
+
});
|
|
71
105
|
});
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* 5. **Canary safety** — bundles MUST inherit redaction. A canary
|
|
23
23
|
* injected through workflow inputs MUST NOT echo verbatim in the
|
|
24
24
|
* bundle response.
|
|
25
|
+
* 6. **Spans join the trace (RFC 0207 §C, v1 only, non-gating)** — for a
|
|
26
|
+
* run started with the suite's `traceparent`, every span's optional
|
|
27
|
+
* `traceId` / `kind` / `status` has the schema's shape, and the
|
|
28
|
+
* `openwop.run` span's `traceId` is the suite's. A host that returns
|
|
29
|
+
* `spans: []` records `inapplicable` ("host emits no spans"), never a
|
|
30
|
+
* pass. v2 has no debug-bundle read (RFC 0207 §C.11), so this leg carries
|
|
31
|
+
* no requirement id and no Accepted bar.
|
|
25
32
|
*
|
|
26
33
|
* Cross-references SECURITY/invariants.yaml `secret-leakage-debug-bundle`.
|
|
27
34
|
*
|
|
@@ -37,6 +44,7 @@ import { CANARY_MARKER, getCanary } from '../lib/canaries.js';
|
|
|
37
44
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
38
45
|
import { req } from '../lib/requirement-ids.js';
|
|
39
46
|
import { softSkip } from '../lib/soft-skip.js';
|
|
47
|
+
import { makeTraceparent } from '../lib/trace-context.js';
|
|
40
48
|
|
|
41
49
|
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
42
50
|
const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
|
|
@@ -222,3 +230,36 @@ describe.skipIf(SKIP_NO_NOOP)('debug-bundle: redaction inheritance per SECURITY/
|
|
|
222
230
|
)).toBe(false);
|
|
223
231
|
});
|
|
224
232
|
});
|
|
233
|
+
|
|
234
|
+
describe.skipIf(SKIP_NO_NOOP)('debug-bundle: spans join the trace (RFC 0207 §C, v1 only, non-gating)', () => {
|
|
235
|
+
const LEG = 'openwop.it.debugBundle.spans-join-the-trace';
|
|
236
|
+
const KINDS = new Set(['internal', 'server', 'client', 'producer', 'consumer']);
|
|
237
|
+
const CODES = new Set(['unset', 'ok', 'error']);
|
|
238
|
+
it('span traceId / kind / status have the RFC 0207 shape and the run span joins the caller trace', async () => {
|
|
239
|
+
if (!(await isAdvertised())) return softSkip('inapplicable', 'debugBundle not advertised by this host');
|
|
240
|
+
const tp = makeTraceparent();
|
|
241
|
+
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID }, { headers: { traceparent: tp.header } });
|
|
242
|
+
expect(create.status).toBe(201);
|
|
243
|
+
const runId = (create.json as { runId: string }).runId;
|
|
244
|
+
await pollUntilTerminal(runId);
|
|
245
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/debug-bundle`);
|
|
246
|
+
expect(res.status).toBe(200);
|
|
247
|
+
const spans = ((res.json as DebugBundleShape | undefined)?.spans ?? []) as Array<Record<string, unknown>>;
|
|
248
|
+
if (spans.length === 0) return softSkip('inapplicable', 'host emits no spans (spans: []) — the RFC 0207 §C trace-join rule has nothing to hold');
|
|
249
|
+
for (const s of spans) {
|
|
250
|
+
const where = `span ${String(s['name'])}`;
|
|
251
|
+
if (s['traceId'] !== undefined) {
|
|
252
|
+
expect(typeof s['traceId'] === 'string' && /^[0-9a-f]{32}$/.test(s['traceId']) && s['traceId'] !== '0'.repeat(32), req(LEG, 'debug-bundle.md §"spans field" (RFC 0207 §C)', `${where}: traceId MUST be 32 lowercase hex, not all zeros (got ${JSON.stringify(s['traceId'])})`)).toBe(true);
|
|
253
|
+
}
|
|
254
|
+
if (s['kind'] !== undefined) expect(KINDS.has(String(s['kind'])), req(LEG, 'debug-bundle.md §"spans field"', `${where}: kind MUST be internal | server | client | producer | consumer (got ${JSON.stringify(s['kind'])})`)).toBe(true);
|
|
255
|
+
if (s['status'] !== undefined) {
|
|
256
|
+
const st = s['status'] as Record<string, unknown> | null;
|
|
257
|
+
const ok = st !== null && typeof st === 'object' && CODES.has(String(st['code'])) && Object.keys(st).every((k) => k === 'code' || (k === 'message' && typeof st['message'] === 'string'));
|
|
258
|
+
expect(ok, req(LEG, 'debug-bundle.md §"spans field"', `${where}: status MUST be the closed { code: unset | ok | error, message? } (got ${JSON.stringify(st)})`)).toBe(true);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const run = spans.find((s) => s['name'] === 'openwop.run');
|
|
262
|
+
if (run === undefined || run['traceId'] === undefined) return softSkip('inapplicable', 'the bundle carries no openwop.run span with a traceId (traceId is SHOULD) — the trace-join half has nothing to compare');
|
|
263
|
+
expect(run['traceId'], req(LEG, 'debug-bundle.md §"spans field" (RFC 0207 §C)', 'the openwop.run span of a run started with an honoured inbound traceparent MUST carry that trace id')).toBe(tp.traceId);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -328,3 +328,75 @@ describe('fixtures: prompt-template schema validity', () => {
|
|
|
328
328
|
}
|
|
329
329
|
});
|
|
330
330
|
});
|
|
331
|
+
|
|
332
|
+
describe('fixtures: interrupt-payload kind binding (v1 + v2 suspend-request)', () => {
|
|
333
|
+
// `fixtures/interrupt-payloads/` holds one minimal InterruptPayload per kind
|
|
334
|
+
// (`interrupt-payload-<kind>.json`) and mismatched kind/data pairs
|
|
335
|
+
// (`negative-<kind>-with-<data-kind>-data.json`). Suite 2.36.0: the `data`
|
|
336
|
+
// union was an unbound `oneOf`, so the minimal `conversation.start` and
|
|
337
|
+
// `conversation.close` payloads — both `{ conversationId }` — matched two
|
|
338
|
+
// branches and FAILED, while a payload whose `data` belonged to another kind
|
|
339
|
+
// passed. The schema now binds each kind to its own shape; every positive
|
|
340
|
+
// MUST validate and every negative MUST NOT, under v1 AND v2.
|
|
341
|
+
//
|
|
342
|
+
// A negative is load-bearing only if it fails for the binding and nothing
|
|
343
|
+
// else, so each one's `data` is also validated under the kind it actually
|
|
344
|
+
// belongs to — a negative that is malformed on its own would pass this test
|
|
345
|
+
// with or without the binding.
|
|
346
|
+
const KINDS = ['approval', 'clarification', 'external-event', 'custom', 'conversation.start', 'conversation.exchange', 'conversation.close', 'low-confidence'];
|
|
347
|
+
const slug = (k: string): string => k.replace('.', '-');
|
|
348
|
+
const dir = join(FIXTURES_DIR, 'interrupt-payloads');
|
|
349
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
|
|
350
|
+
const positives = files.filter((f) => f.startsWith('interrupt-payload-'));
|
|
351
|
+
const negatives = files.filter((f) => f.startsWith('negative-'));
|
|
352
|
+
|
|
353
|
+
function validator(version: 'v1' | 'v2'): (doc: unknown) => { ok: boolean; errors: string } {
|
|
354
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
355
|
+
addFormats(ajv);
|
|
356
|
+
const schemaDir = version === 'v1' ? SCHEMAS_DIR : join(SCHEMAS_DIR, 'v2');
|
|
357
|
+
for (const f of readdirSync(schemaDir).filter((n) => n.endsWith('.schema.json'))) {
|
|
358
|
+
const s = JSON.parse(readFileSync(join(schemaDir, f), 'utf8')) as { $id?: string };
|
|
359
|
+
if (s.$id && ajv.getSchema(s.$id)) continue;
|
|
360
|
+
ajv.addSchema(s);
|
|
361
|
+
}
|
|
362
|
+
const fn = ajv.getSchema(`https://openwop.dev/spec/${version}/suspend-request.schema.json`);
|
|
363
|
+
if (!fn) throw new Error(`suspend-request.schema.json (${version}) did not register`);
|
|
364
|
+
return (doc) => {
|
|
365
|
+
const ok = fn(doc) === true;
|
|
366
|
+
return { ok, errors: (fn.errors ?? []).map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`).join('\n') };
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const load = (f: string): { kind: string; key: string; data: unknown } =>
|
|
370
|
+
JSON.parse(readFileSync(join(dir, f), 'utf8')) as { kind: string; key: string; data: unknown };
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
it('v1 + v2: one positive per kind, and every one validates — including the minimal conversation.start / conversation.close', () => {
|
|
374
|
+
expect(
|
|
375
|
+
positives.map((f) => f.slice('interrupt-payload-'.length, -'.json'.length)).sort(),
|
|
376
|
+
req('openwop.it.fixtures-valid.interrupt-payloads-positive-per-kind-validates', 'spec/v1/interrupt.md §Per-kind payloads; spec/v2/core/interrupt.md', 'fixtures/interrupt-payloads/ MUST carry exactly one interrupt-payload-<kind>.json per InterruptPayload kind'),
|
|
377
|
+
).toEqual(KINDS.map(slug).sort());
|
|
378
|
+
for (const version of ['v1', 'v2'] as const) {
|
|
379
|
+
const validate = validator(version);
|
|
380
|
+
for (const f of positives) {
|
|
381
|
+
const { ok, errors } = validate(load(f));
|
|
382
|
+
expect(ok, req('openwop.it.fixtures-valid.interrupt-payloads-positive-per-kind-validates', 'spec/v1/interrupt.md §Per-kind payloads; spec/v2/core/interrupt.md', `${version}: interrupt-payloads/${f} MUST validate against suspend-request.schema.json:\n${errors}`)).toBe(true);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('v1 + v2: every mismatched kind/data negative is refused, and only because of the binding', () => {
|
|
388
|
+
expect(negatives.length, req('openwop.it.fixtures-valid.interrupt-payloads-mismatched-kind-data-refused', 'spec/v1/interrupt.md §Per-kind payloads; spec/v2/core/interrupt.md', 'fixtures/interrupt-payloads/ MUST carry at least one mismatched kind/data negative')).toBeGreaterThan(0);
|
|
389
|
+
for (const version of ['v1', 'v2'] as const) {
|
|
390
|
+
const validate = validator(version);
|
|
391
|
+
for (const f of negatives) {
|
|
392
|
+
const doc = load(f);
|
|
393
|
+
const dataKind = KINDS.find((k) => f.endsWith(`-with-${slug(k)}-data.json`));
|
|
394
|
+
expect(dataKind, req('openwop.it.fixtures-valid.interrupt-payloads-mismatched-kind-data-refused', 'spec/v1/interrupt.md §Per-kind payloads; spec/v2/core/interrupt.md', `interrupt-payloads/${f} MUST be named negative-<kind>-with-<data-kind>-data.json`)).toBeDefined();
|
|
395
|
+
expect(dataKind === doc.kind, req('openwop.it.fixtures-valid.interrupt-payloads-mismatched-kind-data-refused', 'spec/v1/interrupt.md §Per-kind payloads; spec/v2/core/interrupt.md', `interrupt-payloads/${f}: the data kind MUST differ from the declared kind`)).toBe(false);
|
|
396
|
+
expect(validate(doc).ok, req('openwop.it.fixtures-valid.interrupt-payloads-mismatched-kind-data-refused', 'spec/v1/interrupt.md §Per-kind payloads; spec/v2/core/interrupt.md', `${version}: interrupt-payloads/${f} (kind ${doc.kind} carrying ${String(dataKind)} data) MUST NOT validate — data is bound to kind`)).toBe(false);
|
|
397
|
+
const rebound = validate({ ...doc, kind: dataKind });
|
|
398
|
+
expect(rebound.ok, req('openwop.it.fixtures-valid.interrupt-payloads-mismatched-kind-data-refused', 'spec/v1/interrupt.md §Per-kind payloads; spec/v2/core/interrupt.md', `${version}: interrupt-payloads/${f}'s data MUST validate under its own kind ${String(dataKind)}, or the negative fails for a reason other than the binding:\n${rebound.errors}`)).toBe(true);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0200 §E — a credential the host received inbound never leaves it (suite 2.36.0,
|
|
3
|
+
* both majors; invariant `inbound-credential-no-passthrough`).
|
|
4
|
+
*
|
|
5
|
+
* A host that calls a webhook receiver, an MCP server or an A2A peer on a caller's behalf
|
|
6
|
+
* is a confused deputy the moment it reuses the caller's own `Authorization`, `Cookie` or
|
|
7
|
+
* `Proxy-Authorization` on the outbound request: the destination receives a credential
|
|
8
|
+
* minted for the HOST, carrying the caller's full privilege, and can replay it back. MCP
|
|
9
|
+
* states the same rule for its mounts ("the MCP server MUST NOT pass through the token it
|
|
10
|
+
* received from the MCP client"); OpenWOP had only `trigger-bridge.md` §F.1, which covers
|
|
11
|
+
* inbound headers into RUN DATA and says nothing about egress.
|
|
12
|
+
*
|
|
13
|
+
* **The witness is the suite's own credential.** The suite knows its API key, and it mints
|
|
14
|
+
* two more canaries it sends on the run-creating request. Every outbound request the host
|
|
15
|
+
* makes lands at a receiver the SUITE owns, so the comparison is against bytes the suite
|
|
16
|
+
* controls end to end — not against the host's report of its own behaviour.
|
|
17
|
+
*
|
|
18
|
+
* One requirement id. The unaided leg is the webhook one; it is `blocked`, never a pass,
|
|
19
|
+
* when no delivery is captured, because a hop that never happened leaks nothing.
|
|
20
|
+
*
|
|
21
|
+
* NOT witnessed, and deliberately: a credential the host re-derives or re-encodes, one
|
|
22
|
+
* copied into a nested body field under a name the suite cannot guess, and a DPoP proof
|
|
23
|
+
* (no committed host advertises a sender-constrained lane). The header and raw-body
|
|
24
|
+
* canaries are the witnessed subset of a wider MUST — `SECURITY/invariants.yaml` and
|
|
25
|
+
* `SECURITY/threat-model-secret-leakage.md` §6 say so too.
|
|
26
|
+
*
|
|
27
|
+
* How it FAILS (the sabotage run before citing the row):
|
|
28
|
+
* (a) the host copies the inbound Authorization onto the webhook delivery → the key is
|
|
29
|
+
* found in a captured header
|
|
30
|
+
* (b) the host forwards the inbound Cookie into the delivery body → c1 is found
|
|
31
|
+
*
|
|
32
|
+
* @see spec/v1/auth.md §"Onward hops"; spec/v2/core/security-defaults.md §"Onward hops"
|
|
33
|
+
* @see RFCS/0200-host-as-oauth-protected-resource.md §E
|
|
34
|
+
* @see SECURITY/invariants.yaml id: inbound-credential-no-passthrough
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { afterEach, describe, it, expect } from 'vitest';
|
|
38
|
+
import { randomBytes } from 'node:crypto';
|
|
39
|
+
import { driver } from '../lib/driver.js';
|
|
40
|
+
import { loadEnv } from '../lib/env.js';
|
|
41
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
42
|
+
import { req } from '../lib/requirement-ids.js';
|
|
43
|
+
import { targetMajor } from '../lib/seams.js';
|
|
44
|
+
import { startModalReceiver } from '../lib/webhook-receiver.js';
|
|
45
|
+
import { projectBoundId } from '../lib/bound-id.js';
|
|
46
|
+
|
|
47
|
+
const DOC = 'spec/v1/auth.md §"Onward hops"; spec/v2/core/security-defaults.md §"Onward hops" (RFC 0200 §E)';
|
|
48
|
+
const ID = 'openwop.requirement.0200.inbound-credential-no-passthrough';
|
|
49
|
+
const FIXTURE = 'conformance-noop';
|
|
50
|
+
const WAIT_MS = 15_000;
|
|
51
|
+
|
|
52
|
+
const webhooksPath = (suffix = ''): string => `${targetMajor() === 2 ? '' : '/v1'}/webhooks${suffix}`;
|
|
53
|
+
const runsPath = (suffix = ''): string => `${targetMajor() === 2 ? '' : '/v1'}/runs${suffix}`;
|
|
54
|
+
|
|
55
|
+
let cleanup: (() => Promise<void>) | null = null;
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
if (cleanup) { const c = cleanup; cleanup = null; await c(); }
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
async function waitFor(pred: () => boolean, timeoutMs: number): Promise<boolean> {
|
|
61
|
+
const deadline = Date.now() + timeoutMs;
|
|
62
|
+
while (Date.now() < deadline) {
|
|
63
|
+
if (pred()) return true;
|
|
64
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
65
|
+
}
|
|
66
|
+
return pred();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('RFC 0200 §E — inbound-credential-no-passthrough (the caller\'s credential never leaves the host)', () => {
|
|
70
|
+
it('no webhook delivery carries the caller\'s Authorization, Cookie or Proxy-Authorization', async () => {
|
|
71
|
+
const env = loadEnv();
|
|
72
|
+
const rx = await startModalReceiver();
|
|
73
|
+
let subscriptionId: string | null = null;
|
|
74
|
+
cleanup = async () => {
|
|
75
|
+
if (subscriptionId !== null) { try { await driver.delete(webhooksPath(`/${projectBoundId(subscriptionId)}`)); } catch { /* best effort */ } }
|
|
76
|
+
await rx.close();
|
|
77
|
+
};
|
|
78
|
+
const target = rx.urlFor('no-echo');
|
|
79
|
+
const reg = await driver.post(webhooksPath(), { url: target.url, events: ['run.completed'] });
|
|
80
|
+
if (reg.status === 404 || reg.status === 501) return softSkip('inapplicable', `this host serves no webhook registration (${reg.status}) — it makes no webhook hop for §E to cover`);
|
|
81
|
+
if (reg.status !== 201) {
|
|
82
|
+
return softSkip('blocked', `the subscription could not be registered (${reg.status}) — with no delivery there is no outbound request to inspect${target.tunnelled ? '' : '; set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front if the host refuses the loopback receiver'}`);
|
|
83
|
+
}
|
|
84
|
+
subscriptionId = String((reg.json as { webhookId?: unknown } | null)?.webhookId ?? '') || null;
|
|
85
|
+
|
|
86
|
+
// The three canaries, all sent on the run-creating request. The API key is the one the
|
|
87
|
+
// suite itself authenticates with, so a host that forwards it is handing a receiver a
|
|
88
|
+
// credential for this very host.
|
|
89
|
+
const c1 = `c1-${randomBytes(16).toString('hex')}`;
|
|
90
|
+
const c2 = Buffer.from(`canary:${randomBytes(16).toString('hex')}`).toString('base64');
|
|
91
|
+
const created = await driver.post(runsPath(), { workflowId: FIXTURE }, {
|
|
92
|
+
headers: { Cookie: `ow_canary=${c1}`, 'Proxy-Authorization': `Basic ${c2}` },
|
|
93
|
+
});
|
|
94
|
+
if (created.status !== 201) return softSkip('blocked', `could not start ${FIXTURE} (${created.status}) — no run, no delivery, nothing to inspect`);
|
|
95
|
+
|
|
96
|
+
const delivered = await waitFor(() => rx.hits.some((h) => !h.verification), WAIT_MS);
|
|
97
|
+
// A hop that never happened leaks nothing: this is `blocked`, not a pass.
|
|
98
|
+
if (!delivered) return softSkip('blocked', `no delivery reached the suite-owned receiver within ${WAIT_MS}ms — an unmade hop cannot witness that a credential did not cross it`);
|
|
99
|
+
|
|
100
|
+
const canaries: Array<[string, string]> = [['the suite\'s own API key', env.apiKey], ['the Cookie canary', c1], ['the Proxy-Authorization canary', c2]];
|
|
101
|
+
const found: string[] = [];
|
|
102
|
+
for (const hit of rx.hits) {
|
|
103
|
+
const headerBytes = Object.entries(hit.headers).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(',') : String(v ?? '')}`).join('\n');
|
|
104
|
+
for (const [label, value] of canaries) {
|
|
105
|
+
if (value.length < 8) continue;
|
|
106
|
+
if (headerBytes.includes(value)) found.push(`${label} in a request header of ${hit.method} ${hit.path}`);
|
|
107
|
+
if (hit.body.includes(value)) found.push(`${label} in the delivery body of ${hit.method} ${hit.path}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
expect(
|
|
111
|
+
found,
|
|
112
|
+
req(ID, DOC, `a host MUST NOT attach a credential it received inbound to any outbound request — the suite's API key and two cookie/proxy canaries were sent on the run-creating request and MUST appear in no header or body captured at the suite-owned receiver (${rx.hits.length} capture(s) inspected)`),
|
|
113
|
+
).toEqual([]);
|
|
114
|
+
}, 90_000);
|
|
115
|
+
});
|