@openwop/openwop-conformance 2.35.0 → 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 +27 -0
- package/README.md +3 -3
- package/coverage.md +13 -1
- package/dist/lib/certification-bundle-v3.js +21 -4
- 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/behavior-gate.ts +7 -1
- package/src/lib/certification-bundle-v3.ts +18 -4
- 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,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0207 — trace context across MCP and A2A (suite 2.36.0, target major 2).
|
|
3
|
+
*
|
|
4
|
+
* `spec/v2/core/interop.md` §"Trace context": a host that propagates W3C Trace
|
|
5
|
+
* Context into an MCP request MUST carry it in `params._meta.traceparent`
|
|
6
|
+
* (unprefixed) or in the HTTP `traceparent` header, and SHOULD use `_meta`; into
|
|
7
|
+
* an A2A message, in `Message.metadata.openwop.traceparent` or the HTTP header,
|
|
8
|
+
* SHOULD the metadata. A receiver prefers the in-message value, ignores a
|
|
9
|
+
* malformed one, and never derives authority from either.
|
|
10
|
+
*
|
|
11
|
+
* Every request that drives an outbound call carries the suite's own freshly
|
|
12
|
+
* minted `traceparent` (trace T). The host honours it (`observability.md`
|
|
13
|
+
* §"Trace context propagation", v1-carried text at major 2) and trace context
|
|
14
|
+
* propagates across the MCP and A2A boundaries (`observability.md` §"Trace
|
|
15
|
+
* context across interop boundaries"), so the request the host sends to the
|
|
16
|
+
* suite's own peer is where the carrier is read. The verdict is the D4 rule
|
|
17
|
+
* (`../lib/trace-context.ts` `classifyCarriers`):
|
|
18
|
+
*
|
|
19
|
+
* - NEITHER carrier present → fail;
|
|
20
|
+
* - a carrier with a trace id ≠ T → fail (the host started a new trace);
|
|
21
|
+
* - a malformed carrier → fail;
|
|
22
|
+
* - the header alone → PASS (it conforms; the assertion
|
|
23
|
+
* message and the log line say `header-only`, so the SHOULD stays visible).
|
|
24
|
+
*
|
|
25
|
+
* Legs, each its own id:
|
|
26
|
+
*
|
|
27
|
+
* mcp-traceparent-carried the host's MCP client → the suite's fake MCP
|
|
28
|
+
* server, driven two ways: the production run path
|
|
29
|
+
* (`POST /runs` of `conformance-mcp-client` with the
|
|
30
|
+
* header, gated on `mcp.client`) and the §23 invoke
|
|
31
|
+
* seam (gated on the seams profile);
|
|
32
|
+
* a2a-traceparent-carried the host's A2A client → a suite-owned A2A peer,
|
|
33
|
+
* through the §22 invoke seam;
|
|
34
|
+
* mcp-server-adopts-meta the host as MCP server: `tools/call` on its mount
|
|
35
|
+
* with `_meta.traceparent` = T and a DIFFERENT header
|
|
36
|
+
* H. Observed without OTLP when the mount serves
|
|
37
|
+
* `conformance-mcp-client` as a tool: the run it
|
|
38
|
+
* starts calls the suite's fake server, and that
|
|
39
|
+
* call must carry T, never H. Otherwise read from the
|
|
40
|
+
* suite's OTLP collector; neither path ⇒ inapplicable;
|
|
41
|
+
* mcp-malformed-ignored `_meta.traceparent: "garbage"` (and a garbage
|
|
42
|
+
* header) on the mount is a normal result, never a
|
|
43
|
+
* JSON-RPC error;
|
|
44
|
+
* a2a-malformed-ignored the same on the host's A2A interface (`SendMessage`);
|
|
45
|
+
* a2a-inbound-adopts `SendMessage` with `metadata.openwop.traceparent` =
|
|
46
|
+
* T and header H: the host's exported spans carry T
|
|
47
|
+
* (OTLP only — `inapplicable` when no span arrives).
|
|
48
|
+
*
|
|
49
|
+
* Sabotage (run once against the v2 reference host before citing): send
|
|
50
|
+
* neither carrier → both carrier rows fail; mint a fresh trace → both fail;
|
|
51
|
+
* put the value under a prefixed `_meta` key only → the MCP row fails; the
|
|
52
|
+
* mount adopts the header over `_meta` → adopts-meta fails; answer a malformed
|
|
53
|
+
* value with an error → the malformed rows fail. Header-only is the positive
|
|
54
|
+
* control: it passes.
|
|
55
|
+
*
|
|
56
|
+
* Callback-shaped (the host calls the suite's peers): unwitnessable when the
|
|
57
|
+
* host is in a separate network namespace — `../lib/host-callback.ts`.
|
|
58
|
+
*
|
|
59
|
+
* @see spec/v2/core/interop.md §"Trace context"
|
|
60
|
+
* @see RFCS/0207-trace-context-across-mcp-and-a2a.md §A, §B, Falsifiability
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { describe, it, expect } from 'vitest';
|
|
64
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
65
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
66
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
67
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
68
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
69
|
+
import { loadEnv } from '../lib/env.js';
|
|
70
|
+
import { A2AFakePeer } from '../lib/a2a-fake-peer.js';
|
|
71
|
+
import { McpFakeServer, getMcpFakeServer, type McpInvocation } from '../lib/mcp-fake-server.js';
|
|
72
|
+
import { getCollector } from '../lib/otel-collector.js';
|
|
73
|
+
import { makeTraceparent, classifyCarriers, mcpMetaTraceparent, a2aMetadataTraceparent, parseTraceparent } from '../lib/trace-context.js';
|
|
74
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
75
|
+
import { req } from '../lib/requirement-ids.js';
|
|
76
|
+
|
|
77
|
+
export const REQUIRES_HOST_CALLBACK = "the host's MCP / A2A client calls a suite-owned peer that records the trace-context carriers it receives";
|
|
78
|
+
|
|
79
|
+
const DOC = 'spec/v2/core/interop.md §"Trace context" (RFC 0207)';
|
|
80
|
+
// Top-level string consts, used as req()'s first argument inside each `it`
|
|
81
|
+
// body: generate-requirement-registry resolves exactly that form, so a leg that
|
|
82
|
+
// soft-skips is still recorded under its requirement id.
|
|
83
|
+
const MCP_CARRIED = 'openwop.requirement.0207.mcp-traceparent-carried';
|
|
84
|
+
const A2A_CARRIED = 'openwop.requirement.0207.a2a-traceparent-carried';
|
|
85
|
+
const ADOPTS_META = 'openwop.requirement.0207.mcp-server-adopts-meta';
|
|
86
|
+
const MCP_MALFORMED = 'openwop.requirement.0207.mcp-malformed-ignored';
|
|
87
|
+
const A2A_MALFORMED = 'openwop.requirement.0207.a2a-malformed-ignored';
|
|
88
|
+
const A2A_INBOUND = 'openwop.requirement.0207.a2a-inbound-adopts';
|
|
89
|
+
const MCP_FIXTURE = 'conformance-mcp-client';
|
|
90
|
+
const NOOP = 'conformance-noop';
|
|
91
|
+
const REV = '2026-07-28';
|
|
92
|
+
const META_V = 'io.modelcontextprotocol/protocolVersion';
|
|
93
|
+
const META_C = 'io.modelcontextprotocol/clientCapabilities';
|
|
94
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
95
|
+
const MCP_NAMES = { inMessage: 'params._meta.traceparent' };
|
|
96
|
+
const A2A_NAMES = { inMessage: 'params.message.metadata.openwop.traceparent' };
|
|
97
|
+
|
|
98
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
99
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
100
|
+
|
|
101
|
+
function logCarrier(leg: string, detail: string): void {
|
|
102
|
+
// eslint-disable-next-line no-console
|
|
103
|
+
console.info(`[v2-interop-trace-context] ${leg}: carrier ${detail}${detail === 'header-only' ? ' (conforms; the in-message carrier is SHOULD — RFC 0207 D4)' : ''}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The D4 verdict on one recorded outbound request, as the assertion message and the log line. */
|
|
107
|
+
function carried(leg: string, inMessage: unknown, header: string | undefined, traceId: string, names: { inMessage: string }): { ok: boolean; message: string } {
|
|
108
|
+
const v = classifyCarriers(inMessage, header, traceId, names);
|
|
109
|
+
logCarrier(leg, v.ok ? v.detail : 'none');
|
|
110
|
+
return { ok: v.ok, message: v.ok ? `${leg}: the outbound request carries the suite's trace (carrier: ${v.detail})` : `${leg}: ${v.reason}` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Find a recorded tools/call whose structured-echo nonce is ours. */
|
|
114
|
+
function callWithNonce(invocations: readonly McpInvocation[], nonce: string): McpInvocation | undefined {
|
|
115
|
+
return invocations.find((i) => i.method === 'tools/call' && ((i.params as { arguments?: { nonce?: unknown } } | null)?.arguments?.nonce === nonce));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function waitFor<T>(probe: () => T | undefined, ms: number): Promise<T | undefined> {
|
|
119
|
+
const t0 = Date.now();
|
|
120
|
+
for (;;) { const v = probe(); if (v !== undefined || Date.now() - t0 > ms) return v; await sleep(150); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── the host's MCP mount and A2A interface (host as server) ───────────────
|
|
124
|
+
|
|
125
|
+
type Target = { ok: true; url: string } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
|
|
126
|
+
|
|
127
|
+
async function mcpMount(): Promise<Target> {
|
|
128
|
+
if (!(await v2Discovery().catch(() => null))) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
129
|
+
const facet = await familyAdvertised('mcp');
|
|
130
|
+
if (!facet) return { ok: false, kind: 'inapplicable', reason: 'mcp not advertised at major 2' };
|
|
131
|
+
if (facet['serverMount'] === undefined) return { ok: false, kind: 'inapplicable', reason: 'mcp is advertised without serverMount — the host serves no MCP mount to hold to the receiver rule' };
|
|
132
|
+
const urls = Array.isArray(facet['serverUrls']) ? (facet['serverUrls'] as unknown[]).filter((u): u is string => typeof u === 'string' && u.length > 0) : [];
|
|
133
|
+
if (urls.length === 0) return { ok: false, kind: 'blocked', reason: 'mcp.serverMount is advertised with no mcp.serverUrls[0] — the mount is unaddressable' };
|
|
134
|
+
const u = urls[0]!;
|
|
135
|
+
return { ok: true, url: /^https?:\/\//i.test(u) ? u : `${loadEnv().baseUrl}${u.startsWith('/') ? '' : '/'}${u}` };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface RpcError { code: number; message?: string }
|
|
139
|
+
interface Rpc { status: number; result?: Record<string, unknown> | undefined; error?: RpcError | undefined }
|
|
140
|
+
|
|
141
|
+
async function mcpCall(url: string, method: string, params: Record<string, unknown>, extraMeta: Record<string, unknown>, headers: Record<string, string>): Promise<Rpc> {
|
|
142
|
+
const h: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'MCP-Protocol-Version': REV, 'Mcp-Method': method, ...headers };
|
|
143
|
+
if (method === 'tools/call' && typeof params['name'] === 'string') h['Mcp-Name'] = params['name'];
|
|
144
|
+
const key = loadEnv().apiKey; if (key) h['authorization'] = `Bearer ${key}`;
|
|
145
|
+
const res = await fetch(url, { method: 'POST', headers: h, body: JSON.stringify({ jsonrpc: '2.0', id: randomBytes(4).readUInt32BE(0), method, params: { ...params, _meta: { [META_V]: REV, [META_C]: {}, ...extraMeta } } }) });
|
|
146
|
+
const text = await res.text();
|
|
147
|
+
// A mount may answer a blocking call as one SSE message.
|
|
148
|
+
const json = text.startsWith('event:') || text.startsWith('data:') ? (text.split('\n').find((l) => l.startsWith('data:'))?.slice(5) ?? '{}') : text;
|
|
149
|
+
const body = (() => { try { return JSON.parse(json) as { result?: Record<string, unknown>; error?: RpcError }; } catch { return {}; } })();
|
|
150
|
+
return { status: res.status, ...body };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function mountTools(url: string): Promise<string[]> {
|
|
154
|
+
const r = await mcpCall(url, 'tools/list', {}, {}, {}).catch(() => null);
|
|
155
|
+
const tools = (r?.result?.['tools'] ?? []) as Array<{ name?: unknown }>;
|
|
156
|
+
return tools.map((t) => String(t.name ?? ''));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function a2aInterface(): Promise<Target> {
|
|
160
|
+
if (!(await v2Discovery().catch(() => null))) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
161
|
+
const a2a = await familyAdvertised('a2a');
|
|
162
|
+
if (!a2a) return { ok: false, kind: 'inapplicable', reason: 'a2a not advertised at major 2' };
|
|
163
|
+
const profiles = Array.isArray(a2a['profiles']) ? (a2a['profiles'] as unknown[]) : [];
|
|
164
|
+
if (!profiles.includes('a2a-1.0')) return { ok: false, kind: 'inapplicable', reason: 'a2a is advertised without profile a2a-1.0 — the host serves no A2A interface (client path only)' };
|
|
165
|
+
const cardUrl = a2a['agentCardUrl'];
|
|
166
|
+
if (typeof cardUrl !== 'string') return { ok: false, kind: 'blocked', reason: 'a2a-1.0 is claimed with no a2a.agentCardUrl (v2-a2a-operation-map owns that failure)' };
|
|
167
|
+
const res = await fetch(cardUrl, { headers: { accept: 'application/json', 'A2A-Version': '1.0' } }).catch(() => null);
|
|
168
|
+
const card = (await res?.json().catch(() => ({}))) as { supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }> } | undefined;
|
|
169
|
+
const iface = (card?.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0');
|
|
170
|
+
if (typeof iface?.url !== 'string') return { ok: false, kind: 'blocked', reason: 'the card lists no JSONRPC interface at 1.0 (v2-a2a-operation-map owns that failure)' };
|
|
171
|
+
return { ok: true, url: iface.url };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function a2aCall(url: string, method: string, params: unknown, headers: Record<string, string>): Promise<Rpc> {
|
|
175
|
+
const h: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'A2A-Version': '1.0', ...headers };
|
|
176
|
+
const key = loadEnv().apiKey; if (key) h['authorization'] = `Bearer ${key}`;
|
|
177
|
+
const res = await fetch(url, { method: 'POST', headers: h, body: JSON.stringify({ jsonrpc: '2.0', id: randomBytes(4).readUInt32BE(0), method, params }) });
|
|
178
|
+
const body = (await res.json().catch(() => ({}))) as { result?: Record<string, unknown>; error?: RpcError };
|
|
179
|
+
return { status: res.status, ...body };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const a2aMessage = (metadata: Record<string, unknown>): Record<string, unknown> => ({ message: { messageId: randomUUID(), role: 'ROLE_USER', parts: [{ text: 'rfc 0207 trace context' }], metadata } });
|
|
183
|
+
|
|
184
|
+
/** Best-effort cleanup of a task an A2A leg started (the approval fixture waits forever otherwise). */
|
|
185
|
+
async function cancelTask(url: string, r: Rpc): Promise<void> {
|
|
186
|
+
const res = r.result ?? {};
|
|
187
|
+
const id = ((res['task'] as { id?: unknown } | undefined)?.id ?? res['id']) as unknown;
|
|
188
|
+
if (typeof id === 'string') await a2aCall(url, 'CancelTask', { id }, {}).catch(() => undefined);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Spans the suite collector received for trace `traceId` within `ms`. */
|
|
192
|
+
async function spansFor(traceId: string, ms: number): Promise<number> {
|
|
193
|
+
const c = getCollector(); if (!c) return 0;
|
|
194
|
+
const t0 = Date.now();
|
|
195
|
+
for (;;) {
|
|
196
|
+
const n = c.spans().filter((s) => s.traceId.toLowerCase() === traceId).length;
|
|
197
|
+
if (n > 0 || Date.now() - t0 > ms) return n;
|
|
198
|
+
await sleep(200);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** What the mount adopted, read from one observation path; null when the path cannot see it. */
|
|
203
|
+
interface Adopted { readonly path: string; readonly traces: readonly string[]; readonly error: RpcError | undefined }
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Path 1 — black-box, no OTLP: call `conformance-mcp-client` as a tool; the run
|
|
207
|
+
* it starts calls the suite's fake server, and the trace on THAT call is the
|
|
208
|
+
* parent the host adopted.
|
|
209
|
+
*/
|
|
210
|
+
async function viaOutboundCall(url: string, tools: readonly string[], metaTp: string, headerTp: string): Promise<Adopted | null> {
|
|
211
|
+
const fake = getMcpFakeServer();
|
|
212
|
+
if (!tools.includes(MCP_FIXTURE) || fake === null || (await familyAdvertised('mcp'))?.['client'] !== true) return null;
|
|
213
|
+
const nonce = randomUUID();
|
|
214
|
+
const r = await mcpCall(url, 'tools/call', { name: MCP_FIXTURE, arguments: { method: 'callTool', serverId: 'conformance', name: 'structured-echo', arguments: { nonce } } }, { traceparent: metaTp }, { traceparent: headerTp });
|
|
215
|
+
const sent = await waitFor(() => callWithNonce(fake.invocations(), nonce), 5_000);
|
|
216
|
+
if (sent === undefined) return null;
|
|
217
|
+
const traces = [mcpMetaTraceparent(sent.params), sent.headers['traceparent']].map((v) => parseTraceparent(v)?.traceId).filter((t): t is string => t !== undefined);
|
|
218
|
+
return traces.length > 0 ? { path: 'the outbound call of the run the mount started', traces, error: r.error } : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Path 2 — the suite's OTLP collector: spans for a noop call on trace T or H. */
|
|
222
|
+
async function viaCollector(url: string, tools: readonly string[], t: string, h: string, metaTp: string, headerTp: string): Promise<Adopted | null> {
|
|
223
|
+
const c = getCollector();
|
|
224
|
+
if (c === null || !tools.includes(NOOP)) return null;
|
|
225
|
+
c.reset();
|
|
226
|
+
const r = await mcpCall(url, 'tools/call', { name: NOOP, arguments: {} }, { traceparent: metaTp }, { traceparent: headerTp });
|
|
227
|
+
const onT = await spansFor(t, 5_000);
|
|
228
|
+
const onH = await spansFor(h, 500);
|
|
229
|
+
if (onT + onH === 0) return null;
|
|
230
|
+
return { path: 'the spans exported to the suite collector', traces: [...(onT > 0 ? [t] : []), ...(onH > 0 ? [h] : [])], error: r.error };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
describe('RFC 0207 — outbound carriers (host as MCP / A2A client)', () => {
|
|
234
|
+
it('MCP: the run path carries the suite trace into the tools/call it makes (ctx.mcp)', async () => {
|
|
235
|
+
const doc = await v2Discovery().catch(() => null);
|
|
236
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
237
|
+
const facet = await familyAdvertised('mcp');
|
|
238
|
+
if (!facet || facet['client'] !== true) return softSkip('inapplicable', 'mcp.client not advertised — no run path makes an outbound MCP call (the §23 seam leg covers the client)');
|
|
239
|
+
if (!isFixtureAdvertised(MCP_FIXTURE)) return softSkip('blocked', `mcp.client is advertised without the ${MCP_FIXTURE} fixture — the run path cannot be driven`);
|
|
240
|
+
const fake = getMcpFakeServer();
|
|
241
|
+
if (fake === null) return softSkip('blocked', 'the suite MCP fake server (OPENWOP_MCP_FAKE_SERVER=true) is not started in this run');
|
|
242
|
+
const tp = makeTraceparent();
|
|
243
|
+
const nonce = randomUUID();
|
|
244
|
+
const created = await http(() => driver.post('/runs', { workflowId: MCP_FIXTURE, inputs: { method: 'callTool', serverId: 'conformance', name: 'structured-echo', arguments: { nonce } } }, { headers: { traceparent: tp.header } }));
|
|
245
|
+
const runId = (created?.json as { runId?: unknown } | null)?.runId;
|
|
246
|
+
if (created?.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created?.status ?? 'nothing'} — the ${MCP_FIXTURE} run was refused`);
|
|
247
|
+
const t0 = Date.now();
|
|
248
|
+
while (Date.now() - t0 < 30_000) {
|
|
249
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
250
|
+
if (TERMINAL.has(String((snap?.json as { status?: unknown } | null)?.status ?? ''))) break;
|
|
251
|
+
await sleep(200);
|
|
252
|
+
}
|
|
253
|
+
const sent = await waitFor(() => callWithNonce(fake.invocations(), nonce), 2_000);
|
|
254
|
+
if (!sent) return softSkip('blocked', 'the suite fake server received no structured-echo call carrying this run\'s nonce — the host\'s "conformance" binding does not reach this suite\'s server');
|
|
255
|
+
const v = carried('run path (ctx.mcp.callTool)', mcpMetaTraceparent(sent.params), sent.headers['traceparent'], tp.traceId, MCP_NAMES);
|
|
256
|
+
expect(v.ok, req(MCP_CARRIED, DOC, v.message)).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('MCP: the §23 invoke seam carries the suite trace into tools/call', async () => {
|
|
260
|
+
const doc = await v2Discovery().catch(() => null);
|
|
261
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
262
|
+
if (!(await familyAdvertised('mcp'))) return softSkip('inapplicable', 'mcp not advertised at major 2 — no MCP client to hold');
|
|
263
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the client leg is driven through the §23 invoke seam — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
264
|
+
const server = new McpFakeServer({ protocolVersions: ['2026-07-28'] });
|
|
265
|
+
await server.start();
|
|
266
|
+
try {
|
|
267
|
+
const tp = makeTraceparent();
|
|
268
|
+
const nonce = randomUUID();
|
|
269
|
+
const res = await http(() => driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.hostFacingEndpoint(), tool: 'structured-echo', arguments: { nonce } }, { headers: { traceparent: tp.header } }));
|
|
270
|
+
if (res === null) return softSkip('blocked', `${SEAMS_PREFIX}/sample/mcp/invoke unreachable (fetch failed)`);
|
|
271
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
|
|
272
|
+
const sent = callWithNonce(server.invocations(), nonce);
|
|
273
|
+
if (!sent) return softSkip('blocked', `the seam answered ${res.status} and the suite server received no tools/call carrying the nonce — the host's MCP client never reached it`);
|
|
274
|
+
const v = carried('§23 seam (tools/call)', mcpMetaTraceparent(sent.params), sent.headers['traceparent'], tp.traceId, MCP_NAMES);
|
|
275
|
+
expect(v.ok, req(MCP_CARRIED, DOC, v.message)).toBe(true);
|
|
276
|
+
} finally {
|
|
277
|
+
await server.stop();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('A2A: the §22 invoke seam carries the suite trace into SendMessage', async () => {
|
|
282
|
+
const doc = await v2Discovery().catch(() => null);
|
|
283
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
284
|
+
if (!(await familyAdvertised('a2a'))) return softSkip('inapplicable', 'a2a not advertised at major 2 — no A2A client to hold');
|
|
285
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the client leg is driven through the §22 invoke seam — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
286
|
+
const peer = new A2AFakePeer({ protocolVersions: ['1.0'] });
|
|
287
|
+
await peer.start();
|
|
288
|
+
try {
|
|
289
|
+
const tp = makeTraceparent();
|
|
290
|
+
const res = await http(() => driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint() }, { headers: { traceparent: tp.header } }));
|
|
291
|
+
if (res === null) return softSkip('blocked', `${SEAMS_PREFIX}/sample/a2a/invoke unreachable (fetch failed)`);
|
|
292
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
|
|
293
|
+
const sent = peer.invocations().find((i) => i.rpcMethod === 'SendMessage' || i.rpcMethod === 'message/send');
|
|
294
|
+
if (!sent) return softSkip('blocked', `the seam answered ${res.status} and the suite peer received no SendMessage — the host's A2A client never reached it`);
|
|
295
|
+
const v = carried('§22 seam (SendMessage)', a2aMetadataTraceparent(sent.body), sent.headers['traceparent'], tp.traceId, A2A_NAMES);
|
|
296
|
+
expect(v.ok, req(A2A_CARRIED, DOC, v.message)).toBe(true);
|
|
297
|
+
} finally {
|
|
298
|
+
await peer.stop();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('RFC 0207 — the receiver rule (host as MCP server / A2A server)', () => {
|
|
304
|
+
it('MCP mount: params._meta.traceparent is the parent, not the transport header', async () => {
|
|
305
|
+
const m = await mcpMount(); if (!m.ok) return softSkip(m.kind, m.reason);
|
|
306
|
+
const T = makeTraceparent(); const H = makeTraceparent();
|
|
307
|
+
const tools = await mountTools(m.url);
|
|
308
|
+
const seen = (await viaOutboundCall(m.url, tools, T.header, H.header)) ?? (await viaCollector(m.url, tools, T.traceId, H.traceId, T.header, H.header));
|
|
309
|
+
if (seen === null) return softSkip('inapplicable', 'no observation path reached the suite: the mount serves no MCP-client tool whose outbound call carries a trace, and no span for the call arrived at the suite OTLP collector (REQUIRES_HOST_CALLBACK)');
|
|
310
|
+
expect(seen.error, req(ADOPTS_META, DOC, `the mount call MUST succeed (observed through ${seen.path})`)).toBeUndefined();
|
|
311
|
+
expect(seen.traces.includes(H.traceId), req(ADOPTS_META, DOC, `the host parented its work on the transport header's trace (${H.traceId}) although params._meta.traceparent (${T.traceId}) was present — the receiver MUST prefer the in-message value (observed through ${seen.path})`)).toBe(false);
|
|
312
|
+
expect(seen.traces.every((t) => t === T.traceId), req(ADOPTS_META, DOC, `the work the call started MUST continue the params._meta trace ${T.traceId}; observed [${seen.traces.join(', ')}] through ${seen.path}`)).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('MCP mount: a malformed params._meta.traceparent is ignored, never an error', async () => {
|
|
316
|
+
const m = await mcpMount(); if (!m.ok) return softSkip(m.kind, m.reason);
|
|
317
|
+
if (!(await mountTools(m.url)).includes(NOOP)) return softSkip('blocked', `the mount does not serve ${NOOP} as a tool (conformance/fixtures.md) — the malformed-value leg has nothing to call`);
|
|
318
|
+
const r = await mcpCall(m.url, 'tools/call', { name: NOOP, arguments: {} }, { traceparent: 'garbage', tracestate: '=,=' }, { traceparent: 'garbage' });
|
|
319
|
+
expect(r.error, req(MCP_MALFORMED, DOC, `a malformed traceparent MUST be ignored (a new trace starts), never a request failure — the mount answered JSON-RPC error ${JSON.stringify(r.error)}`)).toBeUndefined();
|
|
320
|
+
expect(r.result, req(MCP_MALFORMED, DOC, 'the call MUST answer a normal result')).toBeDefined();
|
|
321
|
+
expect(r.result?.['isError'] === true, req(MCP_MALFORMED, DOC, 'the noop tool MUST complete normally despite the malformed value')).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('A2A interface: a malformed metadata.openwop.traceparent is ignored, never an error', async () => {
|
|
325
|
+
const t = await a2aInterface(); if (!t.ok) return softSkip(t.kind, t.reason);
|
|
326
|
+
const r = await a2aCall(t.url, 'SendMessage', a2aMessage({ openwop: { traceparent: 'garbage' } }), { traceparent: 'garbage' });
|
|
327
|
+
try {
|
|
328
|
+
expect(r.error, req(A2A_MALFORMED, DOC, `a malformed traceparent MUST be ignored, never a request failure — SendMessage answered error ${JSON.stringify(r.error)}`)).toBeUndefined();
|
|
329
|
+
expect(r.result, req(A2A_MALFORMED, DOC, 'SendMessage MUST answer a normal result')).toBeDefined();
|
|
330
|
+
} finally {
|
|
331
|
+
await cancelTask(t.url, r);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('A2A interface: metadata.openwop.traceparent is the parent, not the transport header', async () => {
|
|
336
|
+
const t = await a2aInterface(); if (!t.ok) return softSkip(t.kind, t.reason);
|
|
337
|
+
if (getCollector() === null) return softSkip('inapplicable', 'the suite OTLP collector is not started — the inbound A2A adopt rule is observable only through exported spans (REQUIRES_HOST_CALLBACK)');
|
|
338
|
+
getCollector()!.reset();
|
|
339
|
+
const T = makeTraceparent(); const H = makeTraceparent();
|
|
340
|
+
const r = await a2aCall(t.url, 'SendMessage', a2aMessage({ openwop: { traceparent: T.header } }), { traceparent: H.header });
|
|
341
|
+
try {
|
|
342
|
+
const onT = await spansFor(T.traceId, 5_000);
|
|
343
|
+
const onH = await spansFor(H.traceId, 500);
|
|
344
|
+
if (onT + onH === 0) return softSkip('inapplicable', 'no span for the SendMessage arrived at the suite OTLP collector — the host exports no OTLP to this suite (REQUIRES_HOST_CALLBACK)');
|
|
345
|
+
expect(onH, req(A2A_INBOUND, DOC, `the host exported ${onH} span(s) on the transport header's trace although Message.metadata.openwop.traceparent was present — the receiver MUST prefer the metadata value`)).toBe(0);
|
|
346
|
+
expect(onT, req(A2A_INBOUND, DOC, 'the run the message started MUST carry the metadata trace')).toBeGreaterThan(0);
|
|
347
|
+
} finally {
|
|
348
|
+
await cancelTask(t.url, r);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0210 §B — an `exp-only` lane's advertised window is a bound the host enforces,
|
|
3
|
+
* not a number it prints (suite 2.36.0, target major 2).
|
|
4
|
+
*
|
|
5
|
+
* `exp-only` is the one member of `identity.md` §2.2's revocation vocabulary that
|
|
6
|
+
* describes a host which re-checks the trust root NEVER: it honours `exp` and consults
|
|
7
|
+
* no introspection endpoint, no userinfo endpoint, no revocation list and no host-side
|
|
8
|
+
* epoch. A credential revoked upstream therefore survives for its full remaining life,
|
|
9
|
+
* and `revocationWindowSeconds` is the ONLY thing standing between "revoked" and "still
|
|
10
|
+
* accepted". That is why this member, alone among the nine it joins, costs something:
|
|
11
|
+
* the host MUST refuse a credential whose total lifetime (`exp − iat`) or remaining
|
|
12
|
+
* lifetime (`exp − now`) exceeds the window it advertised, with a code of its own.
|
|
13
|
+
*
|
|
14
|
+
* **Both bounds are load-bearing and neither implies the other.** `exp − iat` alone
|
|
15
|
+
* accepts a ten-year token minted ten years ago (its remaining life is short, its
|
|
16
|
+
* lifetime is not); `exp − now` alone accepts a freshly minted ten-year token in its
|
|
17
|
+
* ninth year. A host enforcing one and advertising `exp-only` is over-claiming.
|
|
18
|
+
*
|
|
19
|
+
* **The control leg is what makes the two refusals mean anything.** A host that refuses
|
|
20
|
+
* every token passes both sabotage legs and witnesses nothing — the vacuous-witness trap
|
|
21
|
+
* this corpus keeps catching. The control runs in the same `describe`, against the same
|
|
22
|
+
* issuer, the same key and the same token shape, and differs only in the claim under
|
|
23
|
+
* test.
|
|
24
|
+
*
|
|
25
|
+
* **Gate:** an advertised `auth.lanes[]` member with `revocation: "exp-only"`
|
|
26
|
+
* (`inapplicable`, naming that, otherwise) AND `OPENWOP_TEST_OIDC_ISSUER_URL` naming a
|
|
27
|
+
* synthetic issuer the operator has configured that lane to trust (`blocked` otherwise —
|
|
28
|
+
* without a token the host would ever accept, "it refused a string" witnesses nothing).
|
|
29
|
+
* A window under 120 s is `blocked`: the control token needs room inside it.
|
|
30
|
+
*
|
|
31
|
+
* **Each refusal leg is skewed so it isolates ONE bound.** A token minted at `now` with
|
|
32
|
+
* `exp` past the window is outside BOTH bounds, so a host enforcing either one refuses it
|
|
33
|
+
* and the leg cannot tell them apart — measured, not assumed: with the `exp − iat`
|
|
34
|
+
* comparison deleted from the reference host, such a leg stayed green. So the
|
|
35
|
+
* total-lifetime leg dates its `iat` BEHIND the host's clock (§B.4's "ten-year token
|
|
36
|
+
* minted ten years ago": `exp − iat` outside, `exp − now` inside) and the
|
|
37
|
+
* remaining-lifetime leg dates it AHEAD ("a freshly minted ten-year token in its ninth
|
|
38
|
+
* year": `exp − iat` inside, `exp − now` outside).
|
|
39
|
+
*
|
|
40
|
+
* How it FAILS (the sabotage run before citing the rows):
|
|
41
|
+
* (a) delete the `exp − iat` comparison in the host verifier → the total-lifetime leg
|
|
42
|
+
* is served instead of refused;
|
|
43
|
+
* (b) keep only the `exp − iat` comparison → the remaining-lifetime leg is served;
|
|
44
|
+
* (c) refuse with a generic `unauthenticated` → the code leg fails, which is the whole
|
|
45
|
+
* reason §B.6 registers a distinct code: under a generic refusal a sabotage cannot
|
|
46
|
+
* tell "the bound was enforced" from "the host refused for some other reason";
|
|
47
|
+
* (d) refuse every token → the control leg fails, and the two refusal legs stop
|
|
48
|
+
* counting as evidence.
|
|
49
|
+
*
|
|
50
|
+
* @see spec/v2/core/identity.md §2.2
|
|
51
|
+
* @see RFCS/0210-lane-revocation-rule-is-measured.md §B
|
|
52
|
+
* @see SECURITY/invariants.yaml `lane-exp-only-lifetime-bounded`
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { afterAll, describe, it, expect } from 'vitest';
|
|
56
|
+
import { randomBytes } from 'node:crypto';
|
|
57
|
+
import { createServer, type Server } from 'node:http';
|
|
58
|
+
import { driver } from '../lib/driver.js';
|
|
59
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
60
|
+
import { req } from '../lib/requirement-ids.js';
|
|
61
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
62
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
63
|
+
import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
64
|
+
|
|
65
|
+
export const HOST_CALLBACK_NOT_REQUIRED =
|
|
66
|
+
'the suite stands up the synthetic OIDC issuer and the host fetches its JWKS; no request returns to the suite\'s own API, so no host-reachable callback is needed';
|
|
67
|
+
|
|
68
|
+
const DOC = 'spec/v2/core/identity.md §2.2 (RFC 0210 §B)';
|
|
69
|
+
const CODE = 'credential_lifetime_exceeded';
|
|
70
|
+
/** Room inside the window for the control token, and outside it for the two sabotages. */
|
|
71
|
+
const MARGIN = 60;
|
|
72
|
+
|
|
73
|
+
let server: Server | null = null;
|
|
74
|
+
let issuer: SyntheticOIDCIssuer | null = null;
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
if (server !== null) { const s = server; server = null; await new Promise<void>((r) => s.close(() => r())); }
|
|
77
|
+
issuer = null;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
interface Gate {
|
|
81
|
+
readonly issuer: SyntheticOIDCIssuer;
|
|
82
|
+
readonly url: string;
|
|
83
|
+
readonly window: number;
|
|
84
|
+
readonly lane: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The lane, the harness issuer and the window — or the reason this host is not measured.
|
|
89
|
+
*
|
|
90
|
+
* Resolved once per leg rather than in a shared `beforeAll`, so a leg that cannot run
|
|
91
|
+
* records WHY on its own row instead of inheriting a suite-level failure.
|
|
92
|
+
*/
|
|
93
|
+
async function gate(): Promise<Gate | { readonly kind: 'inapplicable' | 'blocked'; readonly reason: string }> {
|
|
94
|
+
let doc: Record<string, unknown> | null;
|
|
95
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
96
|
+
if (doc === null) return { kind: 'blocked', reason: 'v2 discovery unreachable — the lanes cannot be read, so nothing is gated' };
|
|
97
|
+
const auth = await familyAdvertised('auth');
|
|
98
|
+
if (auth === null) return { kind: 'inapplicable', reason: 'the host does not advertise the `auth` family at the v2 root' };
|
|
99
|
+
const lanes = Array.isArray(auth['lanes']) ? (auth['lanes'] as Array<Record<string, unknown>>) : [];
|
|
100
|
+
const expOnly = lanes.filter((l) => l !== null && typeof l === 'object' && l['revocation'] === 'exp-only');
|
|
101
|
+
if (expOnly.length === 0) {
|
|
102
|
+
return {
|
|
103
|
+
kind: 'inapplicable',
|
|
104
|
+
reason: `no advertised lane names revocation "exp-only" (advertised: ${lanes.map((l) => `${String(l['lane'])}=${String(l['revocation'])}`).join(', ') || 'none'}) — §B binds the host that advertises the member and binds no other`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const lane = expOnly[0] as Record<string, unknown>;
|
|
108
|
+
const w = lane['revocationWindowSeconds'];
|
|
109
|
+
if (!Number.isInteger(w) || (w as number) < 1) {
|
|
110
|
+
return { kind: 'blocked', reason: `lane ${String(lane['lane'])} advertises exp-only with no integer revocationWindowSeconds — the window IS the bound, and v2-lane-issuer-advertised fails that document; there is nothing here to measure against` };
|
|
111
|
+
}
|
|
112
|
+
const window = w as number;
|
|
113
|
+
if (window < 2 * MARGIN) {
|
|
114
|
+
return { kind: 'blocked', reason: `lane ${String(lane['lane'])} advertises a ${window}s window; the control token needs ${MARGIN}s of room inside it and the sabotage tokens ${MARGIN}s outside, so a window under ${2 * MARGIN}s cannot be probed without the two cases overlapping` };
|
|
115
|
+
}
|
|
116
|
+
const url = process.env['OPENWOP_TEST_OIDC_ISSUER_URL']?.trim();
|
|
117
|
+
if (!url) {
|
|
118
|
+
return { kind: 'blocked', reason: 'OPENWOP_TEST_OIDC_ISSUER_URL is not set — without an issuer the host is configured to trust, every minted token is refused for the wrong reason and the control leg (which is what makes the refusals non-vacuous) cannot run' };
|
|
119
|
+
}
|
|
120
|
+
const audience = process.env['OPENWOP_TEST_OIDC_AUDIENCE']?.trim() ?? 'openwop-conformance';
|
|
121
|
+
if (issuer === null) {
|
|
122
|
+
const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
|
|
123
|
+
const parsed = new URL(url);
|
|
124
|
+
const srv = createServer((r, res) => {
|
|
125
|
+
if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
|
|
126
|
+
if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
|
|
127
|
+
res.writeHead(404); res.end();
|
|
128
|
+
});
|
|
129
|
+
await new Promise<void>((resolve, reject) => {
|
|
130
|
+
srv.once('error', reject);
|
|
131
|
+
srv.listen(parsed.port ? Number.parseInt(parsed.port, 10) : 80, '127.0.0.1', () => resolve());
|
|
132
|
+
});
|
|
133
|
+
server = srv;
|
|
134
|
+
issuer = made;
|
|
135
|
+
}
|
|
136
|
+
return { issuer, url, window, lane: String(lane['lane']) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A token with the exact `iat`/`exp` pair under test; every other claim is the control's. */
|
|
140
|
+
function mint(g: Gate, iat: number, exp: number): string {
|
|
141
|
+
return g.issuer.mint({ sub: `conformance-${randomBytes(6).toString('hex')}`, iat, exp }).token;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function present(token: string): Promise<{ status: number; code: string | undefined }> {
|
|
145
|
+
const r = await driver.post('/runs', { workflowId: 'conformance-noop' }, { authenticated: false, headers: { Authorization: `Bearer ${token}` } });
|
|
146
|
+
return { status: r.status, code: readErrorCode(r.json) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe('RFC 0210 §B — v2-lane-exp-only-bound (gated on an exp-only lane and a trusted harness issuer)', () => {
|
|
150
|
+
it('a credential inside the advertised window is accepted — the control the two refusals rest on', async () => {
|
|
151
|
+
const g = await gate();
|
|
152
|
+
if (!('issuer' in g)) return softSkip(g.kind, g.reason);
|
|
153
|
+
const now = Math.floor(Date.now() / 1000);
|
|
154
|
+
const r = await present(mint(g, now, now + g.window - MARGIN));
|
|
155
|
+
expect(
|
|
156
|
+
r.status,
|
|
157
|
+
req(
|
|
158
|
+
'openwop.requirement.0210.exp-only-control-accepted',
|
|
159
|
+
DOC,
|
|
160
|
+
`a correctly signed, unexpired credential with exp − iat = ${g.window - MARGIN}s and exp − now = ${g.window - MARGIN}s — INSIDE the ${g.window}s window lane ${g.lane} advertises — MUST NOT be refused; a host that refuses this one refuses everything, and the two lifetime legs below would then witness nothing (got ${r.status} ${r.code ?? ''})`,
|
|
161
|
+
),
|
|
162
|
+
).not.toBe(401);
|
|
163
|
+
}, 60_000);
|
|
164
|
+
|
|
165
|
+
it('a credential whose TOTAL lifetime exceeds the window is refused 401, and so is one carrying no iat', async () => {
|
|
166
|
+
const g = await gate();
|
|
167
|
+
if (!('issuer' in g)) return softSkip(g.kind, g.reason);
|
|
168
|
+
const now = Math.floor(Date.now() / 1000);
|
|
169
|
+
|
|
170
|
+
// exp − iat = window + MARGIN (outside); exp − now = window − MARGIN (inside). The
|
|
171
|
+
// `iat` is BEHIND the host's clock, which is §B.4's "ten-year token minted ten years
|
|
172
|
+
// ago". Minting it at `now` instead would put it outside BOTH bounds, and a host
|
|
173
|
+
// enforcing only `exp − now` would pass this leg — the row would be measuring the
|
|
174
|
+
// other bound. Verified by sabotage: with the `exp − iat` comparison deleted, a
|
|
175
|
+
// token minted at `now` is still refused and this leg goes green over a broken host.
|
|
176
|
+
const over = await present(mint(g, now - 2 * MARGIN, now + g.window - MARGIN));
|
|
177
|
+
expect(
|
|
178
|
+
over.status,
|
|
179
|
+
req(
|
|
180
|
+
'openwop.requirement.0210.exp-only-lifetime-bound',
|
|
181
|
+
DOC,
|
|
182
|
+
`a correctly signed, UNEXPIRED credential with exp − iat = ${g.window + MARGIN}s — beyond the ${g.window}s window lane ${g.lane} advertises, though its exp − now of ${g.window - MARGIN}s is inside it — MUST be refused 401: on an exp-only lane the window is the only bound on how long a revoked subject keeps access (got ${over.status} ${over.code ?? ''})`,
|
|
183
|
+
),
|
|
184
|
+
).toBe(401);
|
|
185
|
+
|
|
186
|
+
// §B.4's second sentence: the first bound cannot be evaluated without `iat`, so the
|
|
187
|
+
// fail-closed rule of §2.1 applies and the refusal carries the same code.
|
|
188
|
+
const noIat = g.issuer.mint({ sub: `conformance-${randomBytes(6).toString('hex')}`, exp: now + g.window - MARGIN, iat: undefined }).token;
|
|
189
|
+
const bare = await present(noIat);
|
|
190
|
+
expect(
|
|
191
|
+
`${bare.status} ${bare.code ?? ''}`.trim(),
|
|
192
|
+
req(
|
|
193
|
+
'openwop.requirement.0210.exp-only-lifetime-bound',
|
|
194
|
+
DOC,
|
|
195
|
+
`a credential carrying NO iat MUST be refused 401 ${CODE} — exp − iat is unevaluable without it, and §2.1 fails closed rather than skipping the bound (got ${bare.status} ${bare.code ?? ''})`,
|
|
196
|
+
),
|
|
197
|
+
).toBe(`401 ${CODE}`);
|
|
198
|
+
}, 60_000);
|
|
199
|
+
|
|
200
|
+
it('the over-lifetime refusal names credential_lifetime_exceeded, not a generic unauthenticated', async () => {
|
|
201
|
+
const g = await gate();
|
|
202
|
+
if (!('issuer' in g)) return softSkip(g.kind, g.reason);
|
|
203
|
+
const now = Math.floor(Date.now() / 1000);
|
|
204
|
+
// Its own `it()` because it is its own requirement (§B.6): one row per id, or the
|
|
205
|
+
// ledger keeps only the last id an assertion cited and the other goes unrecorded.
|
|
206
|
+
// Same token shape as the leg above, re-minted so this row stands on its own run.
|
|
207
|
+
const over = await present(mint(g, now - 2 * MARGIN, now + g.window - MARGIN));
|
|
208
|
+
expect(
|
|
209
|
+
over.code,
|
|
210
|
+
req(
|
|
211
|
+
'openwop.requirement.0210.credential-lifetime-code',
|
|
212
|
+
`${DOC}; spec/v2/errors.json`,
|
|
213
|
+
`a credential with exp − iat = ${g.window + MARGIN}s on the ${g.window}s exp-only lane ${g.lane} MUST be refused with ${CODE}, not a generic unauthenticated — a distinct code is what lets an outside party tell "the bound was enforced" from "the host refused for some other reason" (§B.6) (got ${over.status} ${over.code ?? ''})`,
|
|
214
|
+
),
|
|
215
|
+
).toBe(CODE);
|
|
216
|
+
}, 60_000);
|
|
217
|
+
|
|
218
|
+
it('a credential whose REMAINING lifetime exceeds the window is refused, though its total lifetime does not', async () => {
|
|
219
|
+
const g = await gate();
|
|
220
|
+
if (!('issuer' in g)) return softSkip(g.kind, g.reason);
|
|
221
|
+
const now = Math.floor(Date.now() / 1000);
|
|
222
|
+
// exp − iat = window − MARGIN (inside); exp − now = window + MARGIN (outside). The
|
|
223
|
+
// only way to separate the two bounds is an `iat` ahead of the host's clock, which
|
|
224
|
+
// is the skew case §B.4 names — a host enforcing only `exp − iat` serves this one.
|
|
225
|
+
const iat = now + 2 * MARGIN;
|
|
226
|
+
const skewed = await present(mint(g, iat, iat + g.window - MARGIN));
|
|
227
|
+
expect(
|
|
228
|
+
`${skewed.status} ${skewed.code ?? ''}`.trim(),
|
|
229
|
+
req(
|
|
230
|
+
'openwop.requirement.0210.exp-only-remaining-bound',
|
|
231
|
+
DOC,
|
|
232
|
+
`a credential whose exp − iat is ${g.window - MARGIN}s (inside the ${g.window}s window) but whose exp − now is ${g.window + MARGIN}s (outside it) MUST be refused 401 ${CODE}: enforcing only the total lifetime accepts a freshly minted long-lived token in its ninth year (got ${skewed.status} ${skewed.code ?? ''})`,
|
|
233
|
+
),
|
|
234
|
+
).toBe(`401 ${CODE}`);
|
|
235
|
+
}, 60_000);
|
|
236
|
+
});
|