@openwop/openwop-conformance 2.35.1 → 2.36.1

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 (121) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +3 -3
  3. package/coverage.md +13 -1
  4. package/dist/spec-artifacts.lock.json +2 -2
  5. package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
  6. package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
  7. package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
  8. package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
  9. package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
  10. package/fixtures/a2ui-v09/negative-image-component.json +145 -0
  11. package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
  12. package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
  13. package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
  14. package/fixtures/conformance-artifact-emit.json +28 -0
  15. package/fixtures/conformance-clarification-nested.json +53 -0
  16. package/fixtures/conformance-clarification-sensitive.json +49 -0
  17. package/fixtures/conformance-credential.json +25 -0
  18. package/fixtures/conformance-mcp-client.json +28 -0
  19. package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
  20. package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
  21. package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
  22. package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
  23. package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
  24. package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
  25. package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
  26. package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
  27. package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
  28. package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
  29. package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
  30. package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
  31. package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
  32. package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
  33. package/fixtures/node-pack-runtime/negative-headers.json +37 -0
  34. package/fixtures/node-pack-runtime/negative-http.json +31 -0
  35. package/fixtures/node-pack-runtime/negative-icons.json +37 -0
  36. package/fixtures/node-pack-runtime/negative-meta.json +36 -0
  37. package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
  38. package/fixtures/node-pack-runtime/negative-packages.json +41 -0
  39. package/fixtures/node-pack-runtime/negative-sse.json +31 -0
  40. package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
  41. package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
  42. package/fixtures/node-pack-runtime/negative-variables.json +38 -0
  43. package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
  44. package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
  45. package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
  46. package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
  47. package/fixtures/oauth-providers/synthetic.json +36 -4
  48. package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
  49. package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
  50. package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
  51. package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
  52. package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
  53. package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
  54. package/fixtures.md +139 -0
  55. package/package.json +2 -2
  56. package/requirements.json +5355 -964
  57. package/scenario-majors.json +93 -6
  58. package/schemas/CORPUS-STAMP.json +103 -89
  59. package/src/lib/localized-content.ts +53 -0
  60. package/src/lib/mcp-fake-server.ts +105 -4
  61. package/src/lib/node-pack-runtime.ts +41 -0
  62. package/src/lib/oauth-as-double.ts +316 -0
  63. package/src/lib/protected-resource.ts +88 -0
  64. package/src/lib/standard-webhooks.ts +120 -0
  65. package/src/lib/toolCatalog.ts +54 -4
  66. package/src/lib/trace-context.ts +98 -0
  67. package/src/lib/v2.ts +6 -0
  68. package/src/lib/webhook-receiver.ts +201 -1
  69. package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
  70. package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
  71. package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
  72. package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
  73. package/src/scenarios/debugBundle.test.ts +41 -0
  74. package/src/scenarios/fixtures-valid.test.ts +72 -0
  75. package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
  76. package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
  77. package/src/scenarios/localized-content-delivery.test.ts +26 -15
  78. package/src/scenarios/mcp-2026-07-28-discover.test.ts +22 -7
  79. package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
  80. package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
  81. package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
  82. package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
  83. package/src/scenarios/tool-catalog-projection.test.ts +48 -12
  84. package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
  85. package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
  86. package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
  87. package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
  88. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
  89. package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
  90. package/src/scenarios/v2-auth-challenge.test.ts +119 -0
  91. package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
  92. package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
  93. package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
  94. package/src/scenarios/v2-configurable-closed.test.ts +1 -1
  95. package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
  96. package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
  97. package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
  98. package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
  99. package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
  100. package/src/scenarios/v2-implementation-informational.test.ts +33 -0
  101. package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
  102. package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
  103. package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
  104. package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
  105. package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
  106. package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
  107. package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
  108. package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
  109. package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
  110. package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
  111. package/src/scenarios/v2-run-cancel.test.ts +1 -1
  112. package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
  113. package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
  114. package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
  115. package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
  116. package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
  117. package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
  118. package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
  119. package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
  120. package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
  121. 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
- it('wrong-audience token returns 401 when host trusts the harness', async () => {
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
- expect(wrongAudRes.status, req('openwop.it.auth-oauth2-client-credentials.wrong-audience-token-returns-401-when-host-trusts-the-harness',
193
- 'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
194
- 'token with wrong aud claim MUST return 401',
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 behavioral (capability-gated).
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
- * Status: ACTIVE (capability-gated; behavioral assertion soft-skipped
5
- * until a cross-host MCP/A2A composition test fixture ships). Gated on
6
- * `capabilities.multiAgent.executionModel.version >= 3` AND
7
- * `capabilities.multiAgent.executionModel.crossHostCausation.supported: true`.
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
- * Asserts (when host advertises Phase 3 + a real MCP/A2A composition
10
- * endpoint is reachable):
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
- * 1. An outbound MCP tool call dispatched from a Phase 3 host MUST
13
- * carry the parent run's W3C `traceparent` header. The MCP server
14
- * receives the header AND uses it as the parent trace for any
15
- * spans it emits (closing the cross-host span linkage that
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
- // Behavioral assertions in this file are currently `it.skip` placeholders;
39
- // the cross-host MCP / A2A peer harness (gated on OPENWOP_MCP_REAL_SERVER_URL
40
- // / OPENWOP_A2A_REAL_PEER_URL) hasn't landed yet. When it does, the
41
- // `it.skip` calls flip back to runnable `it(...)` bodies that read discovery
42
- // (via `driver.get('/.well-known/openwop')`), gate on `Phase 3` advertisement,
43
- // and drive the workflow through the configured real peer.
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: behavioral (RFC 0040 §B)', () => {
46
- // Behavioral assertion drives a workflow that calls an MCP tool via the
47
- // host's `core.mcp.toolCall` node. The MCP peer (configured via
48
- // OPENWOP_MCP_REAL_SERVER_URL) records inbound headers; the test reads
49
- // the recorded headers and asserts `traceparent` is present + matches
50
- // the format `00-{traceId}-{spanId}-{flags}` per W3C tracecontext.
51
- // Until the peer harness lands, the assertion is surfaced as `it.skip` so
52
- // test reporters track the gap rather than reporting a vacuous PASS.
53
- // Marked out of stable profile via RFC 0042 §B (experimental tier):
54
- // RFC 0040 is Accepted, but the cross-host behavioral scenario stays
55
- // experimental until a non-steward host produces the behavioral
56
- // traceparent evidence — RFC status (Accepted) and conformance-profile
57
- // tier (experimental) are separate axes per RFC 0042 §B. Hosts that wire
58
- // Phase 3 cross-host causation SHOULD advertise
59
- // `multiAgent.executionModel.tier: 'experimental'` per RFC 0042 §A
60
- // until that behavioral evidence lands. Path-to-runnable
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
- // Same routing — out of stable profile via RFC 0042 §B until behavioral
67
- // A2A cross-host evidence lands (RFC 0040 itself is already Accepted); the
68
- // A2A test seam contract is still to be designed alongside the
69
- // corresponding peer harness.
70
- it.skip('Phase 3 host MUST inject parent run\'s traceparent into outbound A2A messages — out of stable profile via RFC 0042');
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
+ });