@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,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-a2ui-v09-surface — RFC 0209 §A.1, §A.2, §C.9, §C.11, §C.12, the seam-gated
|
|
3
|
+
* legs (suite 2.36.0, target major 2).
|
|
4
|
+
*
|
|
5
|
+
* `ui.a2ui-surface` at per-kind schema version 2 carries A2UI v0.9 messages. The
|
|
6
|
+
* suite cannot make a model emit a chosen envelope, so every leg here drives the
|
|
7
|
+
* v2 emit-surface seam (`POST /conformance/seams/sample/a2ui/emit-surface`,
|
|
8
|
+
* api/seams-v2.yaml), which a host MUST route through its production envelope
|
|
9
|
+
* admission. The server-free legs (profile ⊂ upstream, the excluded affordances,
|
|
10
|
+
* the pinned catalog, the kind carve-out) are corpus rows in
|
|
11
|
+
* `src/coherence/a2ui-v09-profile.test.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Gates, all decided before any assertion so a skipped leg never records a
|
|
14
|
+
* partial witness: the seams profile advertised; `schemaVersions.kinds` giving
|
|
15
|
+
* the kind a floor of at least 2 (version-2 legs) or exactly 1 (the legacy leg);
|
|
16
|
+
* the seam answering something other than 404/405. Any of these absent ⇒
|
|
17
|
+
* `inapplicable` — a leg never passes on a non-answer.
|
|
18
|
+
*
|
|
19
|
+
* Every refusal leg carries an admitted control in the same run, so a host that
|
|
20
|
+
* refuses everything fails as surely as one that refuses nothing.
|
|
21
|
+
*
|
|
22
|
+
* @see RFCS/0209-v2-a2ui-surfaces-are-a2ui-v0-9.md
|
|
23
|
+
* @see spec/v2/ext/a2uiSurface/README.md
|
|
24
|
+
* @see spec/v2/core/events.md §"The envelope-kind catalog"
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
31
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
32
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
33
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
|
+
import { FIXTURES_DIR } from '../lib/paths.js';
|
|
35
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
36
|
+
import { req } from '../lib/requirement-ids.js';
|
|
37
|
+
|
|
38
|
+
type Json = Record<string, unknown>;
|
|
39
|
+
const KIND = 'ui.a2ui-surface';
|
|
40
|
+
const SEAM = `${SEAMS_PREFIX}/sample/a2ui/emit-surface`;
|
|
41
|
+
const FIXTURE = 'conformance-approval';
|
|
42
|
+
const GATE_NODE = 'gate';
|
|
43
|
+
const CATALOG = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json';
|
|
44
|
+
const clone = <T>(o: T): T => JSON.parse(JSON.stringify(o)) as T;
|
|
45
|
+
const POSITIVE = JSON.parse(readFileSync(join(FIXTURES_DIR, 'a2ui-v09', 'positive-approve-brief.json'), 'utf8')) as Json;
|
|
46
|
+
const V1_SURFACE: Json = { catalogVersion: '0.9.1', surface: { title: 'Kickoff', components: [{ component: 'heading', text: 'Kickoff', level: 2 }, { component: 'action.button', id: 'go', label: 'Go', action: { target: 'resume' } }] } };
|
|
47
|
+
|
|
48
|
+
let seq = 0;
|
|
49
|
+
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
50
|
+
function envelope(schemaVersion: number, payload: unknown, opts: { trust?: 'trusted' | 'untrusted'; nodeId?: string } = {}): Json {
|
|
51
|
+
seq += 1;
|
|
52
|
+
const meta: Json = { source: 'ai-generation', ts: new Date().toISOString() };
|
|
53
|
+
if (opts.trust) meta['contentTrust'] = opts.trust;
|
|
54
|
+
return { type: KIND, schemaVersion, envelopeId: `env-0209-${nonce}-${seq}`, correlationId: `corr-0209-${nonce}-${seq}`, ...(opts.nodeId ? { nodeId: opts.nodeId } : {}), payload, meta };
|
|
55
|
+
}
|
|
56
|
+
/** A version-2 payload for `surfaceId` carrying `messages` (surfaceId stamped into each). */
|
|
57
|
+
function v2(surfaceId: string, messages: Json[]): Json {
|
|
58
|
+
return { version: 'v0.9', catalogId: CATALOG, surfaceId, messages: messages.map((m) => { const c = clone(m); const body = Object.entries(c).find(([k]) => k !== 'version')?.[1] as Json; body['surfaceId'] = surfaceId; return c; }) };
|
|
59
|
+
}
|
|
60
|
+
const createMsg = (): Json => ({ version: 'v0.9', createSurface: { surfaceId: '', catalogId: CATALOG } });
|
|
61
|
+
const componentsMsg = (): Json => clone((POSITIVE['messages'] as Json[])[1]);
|
|
62
|
+
const dataMsg = (): Json => ({ version: 'v0.9', updateDataModel: { surfaceId: '', value: { name: 'x' } } });
|
|
63
|
+
const deleteMsg = (): Json => ({ version: 'v0.9', deleteSurface: { surfaceId: '' } });
|
|
64
|
+
const sid = (tag: string): string => `s-${tag}-${nonce}`;
|
|
65
|
+
|
|
66
|
+
async function emit(runId: string, env: Json): Promise<OpenWOPResponse> {
|
|
67
|
+
return driver.post(SEAM, { runId, envelope: env });
|
|
68
|
+
}
|
|
69
|
+
const code = (r: OpenWOPResponse): string => `${r.status} ${readErrorCode(r.json) ?? ''}`.trim();
|
|
70
|
+
|
|
71
|
+
async function waitStatus(runId: string, wanted: ReadonlySet<string>, timeoutMs: number): Promise<string | null> {
|
|
72
|
+
const deadline = Date.now() + timeoutMs;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
75
|
+
const status = res.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
76
|
+
if (status !== null && wanted.has(status)) return status;
|
|
77
|
+
if (Date.now() > deadline) return status;
|
|
78
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type Ctx = { readonly floor: number; readonly strict: boolean; readonly runId: string };
|
|
83
|
+
type Gate = { ok: true; ctx: Ctx } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
|
|
84
|
+
|
|
85
|
+
/** Everything a leg needs, decided before its first assertion. `want` is the floor predicate. */
|
|
86
|
+
async function gate(want: (floor: number) => boolean, wantText: string): Promise<Gate> {
|
|
87
|
+
const doc = await v2Discovery();
|
|
88
|
+
if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
89
|
+
if (!seamsProfileAdvertised(doc)) return { ok: false, kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the emit-surface seam is the only way the suite can choose an envelope (RFC 0209 Falsifiability)' };
|
|
90
|
+
const kinds = ((doc['schemaVersions'] as Json | undefined)?.['kinds'] ?? {}) as Json;
|
|
91
|
+
const floor = typeof kinds[KIND] === 'number' ? (kinds[KIND] as number) : 0;
|
|
92
|
+
if (!want(floor)) return { ok: false, kind: 'inapplicable', reason: `schemaVersions.kinds["${KIND}"] is ${floor}; this leg needs ${wantText}` };
|
|
93
|
+
const strict = ((doc['envelopeStrictness'] as Json | undefined)?.['mode']) === 'strict';
|
|
94
|
+
const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
|
|
95
|
+
if (!fixtures.includes(FIXTURE)) return { ok: false, kind: 'inapplicable', reason: `the host does not advertise the ${FIXTURE} fixture the legs run inside` };
|
|
96
|
+
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
97
|
+
if (create.status !== 201) return { ok: false, kind: 'blocked', reason: `POST /runs {workflowId: ${FIXTURE}} answered ${code(create)}` };
|
|
98
|
+
const runId = (create.json as { runId: string }).runId;
|
|
99
|
+
const status = await waitStatus(runId, new Set(['waiting-approval', 'completed', 'failed', 'cancelled']), 10_000);
|
|
100
|
+
if (status !== 'waiting-approval') return { ok: false, kind: 'blocked', reason: `${FIXTURE} did not suspend (status ${status})` };
|
|
101
|
+
// Probe the seam with a payload every host that has it must refuse (no createSurface first), so the probe records nothing.
|
|
102
|
+
const probe = await emit(runId, envelope(floor, floor >= 2 ? v2(sid('probe'), [dataMsg()]) : { catalogVersion: '9.9.9', surface: { components: [] } }));
|
|
103
|
+
if (probe.status === 404 || probe.status === 405) {
|
|
104
|
+
await driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {});
|
|
105
|
+
return { ok: false, kind: 'inapplicable', reason: `${SEAM} answered ${probe.status} — the emit-surface seam is not wired` };
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, ctx: { floor, strict, runId } };
|
|
108
|
+
}
|
|
109
|
+
async function done(runId: string): Promise<void> { await driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}); }
|
|
110
|
+
|
|
111
|
+
/** Every subtree of `v`, depth-first. */
|
|
112
|
+
function* subtrees(v: unknown): Generator<unknown> {
|
|
113
|
+
yield v;
|
|
114
|
+
if (Array.isArray(v)) for (const x of v) yield* subtrees(x);
|
|
115
|
+
else if (v && typeof v === 'object') for (const x of Object.values(v)) yield* subtrees(x);
|
|
116
|
+
}
|
|
117
|
+
const carries = (events: unknown[], payload: unknown): boolean => {
|
|
118
|
+
const want = JSON.stringify(payload);
|
|
119
|
+
return events.some((e) => [...subtrees((e as Json)['payload'])].some((s) => JSON.stringify(s) === want));
|
|
120
|
+
};
|
|
121
|
+
async function eventsOf(runId: string): Promise<unknown[]> {
|
|
122
|
+
const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
123
|
+
const ev = (r.json as { events?: unknown } | null)?.events;
|
|
124
|
+
return Array.isArray(ev) ? ev : [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe('RFC 0209 §A.1 — the schema version selects the branch (seam-gated)', () => {
|
|
128
|
+
it('a version-2 envelope carrying a version-1 body is refused; the positive is admitted; above the floor is refused; below it follows envelopeStrictness', async () => {
|
|
129
|
+
const g = await gate((f) => f >= 2, 'a floor of at least 2');
|
|
130
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
131
|
+
const { runId, floor, strict } = g.ctx;
|
|
132
|
+
try {
|
|
133
|
+
const good = await emit(runId, envelope(2, v2(sid('vsb-good'), (POSITIVE['messages'] as Json[]))));
|
|
134
|
+
expect(good.status, req('openwop.requirement.0209.version-selects-branch', 'RFC 0209 §A.1', `the positive version-2 surface MUST be admitted (201), got ${code(good)}`)).toBe(201);
|
|
135
|
+
expect(typeof (good.json as { sequence?: unknown } | null)?.sequence, req('openwop.requirement.0209.version-selects-branch', 'api/seams-v2.yaml emitA2uiSurface', 'a 201 names the recording event sequence')).toBe('number');
|
|
136
|
+
const mixed = await emit(runId, envelope(2, V1_SURFACE));
|
|
137
|
+
expect(mixed.status, req('openwop.requirement.0209.version-selects-branch', 'RFC 0209 §A.1', `a schemaVersion 2 envelope with a payloadV1 body MUST be refused — validating against the anyOf union would admit it (got ${code(mixed)})`)).toBe(422);
|
|
138
|
+
expect(readErrorCode(mixed.json), req('openwop.requirement.0209.version-selects-branch', 'spec/v2/ext/a2uiSurface/README.md §Versions', 'the refusal is envelope_invalid')).toBe('envelope_invalid');
|
|
139
|
+
const above = await emit(runId, envelope(floor + 1, v2(sid('vsb-above'), [createMsg()])));
|
|
140
|
+
expect(code(above), req('openwop.requirement.0209.version-selects-branch', 'events.md §"The envelope-kind catalog"', 'a schemaVersion above the floor MUST be refused with unknown_schema_version whatever the strictness')).toBe('422 unknown_schema_version');
|
|
141
|
+
const below = await emit(runId, envelope(1, v2(sid('vsb-below'), [createMsg()])));
|
|
142
|
+
if (strict) {
|
|
143
|
+
expect(code(below), req('openwop.requirement.0209.version-selects-branch', 'events.md §"The envelope-kind catalog"', 'under strict, a schemaVersion 1 envelope below floor 2 MUST be refused with unknown_schema_version')).toBe('422 unknown_schema_version');
|
|
144
|
+
} else {
|
|
145
|
+
expect(below.status, req('openwop.requirement.0209.version-selects-branch', 'events.md §"The envelope-kind catalog"', `under warn, a schemaVersion 1 envelope is validated against the advertised version 2; a payloadV2 body is therefore admitted with a drift log (got ${code(below)})`)).toBe(201);
|
|
146
|
+
const belowV1 = await emit(runId, envelope(1, V1_SURFACE));
|
|
147
|
+
expect(code(belowV1), req('openwop.requirement.0209.version-selects-branch', 'RFC 0209 §A.1', 'under warn, a schemaVersion 1 envelope with a payloadV1 body is validated against version 2 and MUST be refused')).toBe('422 envelope_invalid');
|
|
148
|
+
}
|
|
149
|
+
} finally { await done(runId); }
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('RFC 0209 §C.9 — the fold is guarded at record time (seam-gated)', () => {
|
|
154
|
+
it('no first createSurface, a message after deleteSurface, and a second createSurface are each refused; the valid steps are admitted', async () => {
|
|
155
|
+
const g = await gate((f) => f >= 2, 'a floor of at least 2');
|
|
156
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
157
|
+
const { runId } = g.ctx;
|
|
158
|
+
const R = (why: string) => req('openwop.requirement.0209.fold-guarded', 'RFC 0209 §C.9', why);
|
|
159
|
+
try {
|
|
160
|
+
const orphan = await emit(runId, envelope(2, v2(sid('fold-a'), [componentsMsg()])));
|
|
161
|
+
expect(code(orphan), R('the first envelope for a surfaceId MUST begin with createSurface — updateComponents alone is refused, not recorded')).toBe('422 envelope_invalid');
|
|
162
|
+
const bCreate = await emit(runId, envelope(2, v2(sid('fold-b'), [createMsg(), componentsMsg()])));
|
|
163
|
+
expect(bCreate.status, R(`createSurface + updateComponents for a new surfaceId MUST be admitted (control), got ${code(bCreate)}`)).toBe(201);
|
|
164
|
+
const bDelete = await emit(runId, envelope(2, v2(sid('fold-b'), [deleteMsg()])));
|
|
165
|
+
expect(bDelete.status, R(`deleteSurface on a live surface MUST be admitted (control), got ${code(bDelete)}`)).toBe(201);
|
|
166
|
+
const bAfter = await emit(runId, envelope(2, v2(sid('fold-b'), [dataMsg()])));
|
|
167
|
+
expect(code(bAfter), R('a message after deleteSurface without a new createSurface MUST be refused')).toBe('422 envelope_invalid');
|
|
168
|
+
const cCreate = await emit(runId, envelope(2, v2(sid('fold-c'), [createMsg()])));
|
|
169
|
+
expect(cCreate.status, R(`createSurface for a new surfaceId MUST be admitted (control), got ${code(cCreate)}`)).toBe(201);
|
|
170
|
+
const cAgain = await emit(runId, envelope(2, v2(sid('fold-c'), [createMsg()])));
|
|
171
|
+
expect(code(cAgain), R('createSurface for a live surfaceId MUST be refused')).toBe('422 envelope_invalid');
|
|
172
|
+
const bRecreate = await emit(runId, envelope(2, v2(sid('fold-b'), [createMsg(), dataMsg()])));
|
|
173
|
+
expect(bRecreate.status, R(`a new createSurface after deleteSurface MUST be admitted (control), got ${code(bRecreate)}`)).toBe(201);
|
|
174
|
+
} finally { await done(runId); }
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('RFC 0209 §A.2 — the cross-field rules are checked at admission (seam-gated)', () => {
|
|
179
|
+
it('a createSurface.catalogId differing from the payload catalogId is refused', async () => {
|
|
180
|
+
const g = await gate((f) => f >= 2, 'a floor of at least 2');
|
|
181
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
182
|
+
const { runId } = g.ctx;
|
|
183
|
+
try {
|
|
184
|
+
const p = v2(sid('cat'), [createMsg()]);
|
|
185
|
+
((p['messages'] as Json[])[0]['createSurface'] as Json)['catalogId'] = 'https://a2ui.org/specification/v0_9/catalogs/other/catalog.json';
|
|
186
|
+
const r = await emit(runId, envelope(2, p));
|
|
187
|
+
expect(code(r), req('openwop.requirement.0209.catalog-equality', 'RFC 0209 §A.2', 'a createSurface.catalogId MUST equal the payload catalogId; a differing one is refused, not recorded')).toBe('422 envelope_invalid');
|
|
188
|
+
const ok = await emit(runId, envelope(2, v2(sid('cat-ok'), [createMsg()])));
|
|
189
|
+
expect(ok.status, req('openwop.requirement.0209.catalog-equality', 'RFC 0209 §A.2', `the same payload with equal catalogIds MUST be admitted (control), got ${code(ok)}`)).toBe(201);
|
|
190
|
+
} finally { await done(runId); }
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('a message whose surfaceId differs from the payload surfaceId is refused', async () => {
|
|
194
|
+
const g = await gate((f) => f >= 2, 'a floor of at least 2');
|
|
195
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
196
|
+
const { runId } = g.ctx;
|
|
197
|
+
try {
|
|
198
|
+
const p = v2(sid('sid'), [createMsg(), componentsMsg()]);
|
|
199
|
+
((p['messages'] as Json[])[1]['updateComponents'] as Json)['surfaceId'] = sid('sid-other');
|
|
200
|
+
const r = await emit(runId, envelope(2, p));
|
|
201
|
+
expect(code(r), req('openwop.it.v2-a2ui-v09-surface.surface-id-equality', 'RFC 0209 §A.2', 'every message surfaceId MUST equal the payload surfaceId — the schema cannot express it, so the engine MUST refuse a mismatch')).toBe('422 envelope_invalid');
|
|
202
|
+
const ok = await emit(runId, envelope(2, v2(sid('sid'), [createMsg(), componentsMsg()])));
|
|
203
|
+
expect(ok.status, req('openwop.it.v2-a2ui-v09-surface.surface-id-equality', 'RFC 0209 §A.2', `the refused envelope left no trace: the same surfaceId is still new and admits createSurface (got ${code(ok)})`)).toBe(201);
|
|
204
|
+
} finally { await done(runId); }
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('RFC 0209 §C.11 — recorded surfaces are returned as recorded (seam-gated)', () => {
|
|
209
|
+
it('a recorded version-2 surface is returned byte-equal on poll and carried by a fork at a later fromSeq', async () => {
|
|
210
|
+
const g = await gate((f) => f >= 2, 'a floor of at least 2');
|
|
211
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
212
|
+
const { runId } = g.ctx;
|
|
213
|
+
try {
|
|
214
|
+
const payload = v2(sid('rec'), (POSITIVE['messages'] as Json[]));
|
|
215
|
+
const r = await emit(runId, envelope(2, payload));
|
|
216
|
+
expect(r.status, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', `the positive surface MUST be admitted, got ${code(r)}`)).toBe(201);
|
|
217
|
+
const sequence = (r.json as { sequence: number }).sequence;
|
|
218
|
+
const events = await eventsOf(runId);
|
|
219
|
+
expect(carries(events, payload), req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', 'the run\'s poll MUST return the recorded surface payload byte-equal — no surface is regenerated')).toBe(true);
|
|
220
|
+
const fork = await driver.post(`/runs/${encodeURIComponent(runId)}:fork`, { mode: 'replay', fromSeq: sequence + 1 });
|
|
221
|
+
expect(fork.status, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', `POST :fork at fromSeq ${sequence + 1} MUST answer 201, got ${code(fork)}`)).toBe(201);
|
|
222
|
+
const forkId = (fork.json as { runId: string }).runId;
|
|
223
|
+
expect(carries(await eventsOf(forkId), payload), req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', 'a fork at a fromSeq after the surface MUST carry it unchanged')).toBe(true);
|
|
224
|
+
await done(forkId);
|
|
225
|
+
} finally { await done(runId); }
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('a version-1 surface recorded at floor 1 is returned byte-equal on poll and carried by a fork', async () => {
|
|
229
|
+
const g = await gate((f) => f === 1, 'a floor of exactly 1 — a host whose floor is 2 has no admission path for a version-1 surface, and the suite cannot seed one (RFC 0209 §C.11 holds for what it recorded before raising the floor)');
|
|
230
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
231
|
+
const { runId } = g.ctx;
|
|
232
|
+
try {
|
|
233
|
+
const r = await emit(runId, envelope(1, V1_SURFACE));
|
|
234
|
+
expect(r.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `a version-1 surface at floor 1 MUST be admitted, got ${code(r)}`)).toBe(201);
|
|
235
|
+
const sequence = (r.json as { sequence: number }).sequence;
|
|
236
|
+
expect(carries(await eventsOf(runId), V1_SURFACE), req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', 'the poll MUST return the version-1 surface byte-equal')).toBe(true);
|
|
237
|
+
const fork = await driver.post(`/runs/${encodeURIComponent(runId)}:fork`, { mode: 'replay', fromSeq: sequence + 1 });
|
|
238
|
+
expect(fork.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `POST :fork MUST answer 201, got ${code(fork)}`)).toBe(201);
|
|
239
|
+
const forkId = (fork.json as { runId: string }).runId;
|
|
240
|
+
expect(carries(await eventsOf(forkId), V1_SURFACE), req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', 'a fork at a later fromSeq MUST carry the version-1 surface unchanged')).toBe(true);
|
|
241
|
+
await done(forkId);
|
|
242
|
+
} finally { await done(runId); }
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('RFC 0209 §C.12 — trust is sticky across the fold (seam-gated)', () => {
|
|
247
|
+
it('one untrusted update taints a trusted surface, a later trusted update does not launder it, and the approval does not advance; an all-trusted surface does', async () => {
|
|
248
|
+
const g = await gate((f) => f >= 2, 'a floor of at least 2');
|
|
249
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
250
|
+
const { runId } = g.ctx;
|
|
251
|
+
const R = (why: string) => req('openwop.requirement.0209.taint-sticky', 'RFC 0209 §C.12', why);
|
|
252
|
+
let controlRun: string | null = null;
|
|
253
|
+
try {
|
|
254
|
+
const s = sid('taint');
|
|
255
|
+
const steps = [
|
|
256
|
+
await emit(runId, envelope(2, v2(s, [createMsg(), componentsMsg()]), { nodeId: GATE_NODE })),
|
|
257
|
+
await emit(runId, envelope(2, v2(s, [componentsMsg()]), { nodeId: GATE_NODE, trust: 'untrusted' })),
|
|
258
|
+
await emit(runId, envelope(2, v2(s, [componentsMsg()]), { nodeId: GATE_NODE, trust: 'trusted' })),
|
|
259
|
+
];
|
|
260
|
+
steps.forEach((r, i) => expect(r.status, R(`surface step ${i} MUST be recorded (201) — an untrusted envelope is recorded and tainted, not refused; got ${code(r)}`)).toBe(201));
|
|
261
|
+
const resolve = await driver.post(`/runs/${encodeURIComponent(runId)}/interrupts/${GATE_NODE}`, { resumeValue: { action: 'accept' } });
|
|
262
|
+
expect(resolve.status >= 400 && resolve.status < 500, R(`resolving the approval bound to a tainted surface MUST be refused (4xx), got ${code(resolve)}`)).toBe(true);
|
|
263
|
+
const text = JSON.stringify(resolve.json ?? {});
|
|
264
|
+
expect(text.includes('untrusted_content_blocks_approval'), R(`the refusal MUST name untrusted_content_blocks_approval (in error.details or message): ${text.slice(0, 300)}`)).toBe(true);
|
|
265
|
+
expect(await waitStatus(runId, new Set(['waiting-approval']), 1_000), R('a refused resolve MUST NOT advance the run — the approval stays pending')).toBe('waiting-approval');
|
|
266
|
+
|
|
267
|
+
// Control: the same fold with no untrusted envelope MUST let the approval advance, or the refusal above proves nothing.
|
|
268
|
+
const ctl = await driver.post('/runs', { workflowId: FIXTURE });
|
|
269
|
+
expect(ctl.status, R(`control run MUST start, got ${code(ctl)}`)).toBe(201);
|
|
270
|
+
controlRun = (ctl.json as { runId: string }).runId;
|
|
271
|
+
expect(await waitStatus(controlRun, new Set(['waiting-approval', 'completed', 'failed', 'cancelled']), 10_000), R('the control run MUST suspend on its approval')).toBe('waiting-approval');
|
|
272
|
+
const c = sid('trusted');
|
|
273
|
+
const c1 = await emit(controlRun, envelope(2, v2(c, [createMsg(), componentsMsg()]), { nodeId: GATE_NODE }));
|
|
274
|
+
const c2 = await emit(controlRun, envelope(2, v2(c, [componentsMsg()]), { nodeId: GATE_NODE, trust: 'trusted' }));
|
|
275
|
+
expect([c1.status, c2.status], R('the control surface MUST be recorded')).toEqual([201, 201]);
|
|
276
|
+
const ok = await driver.post(`/runs/${encodeURIComponent(controlRun)}/interrupts/${GATE_NODE}`, { resumeValue: { action: 'accept' } });
|
|
277
|
+
expect(ok.status, R(`resolving the approval bound to an all-trusted surface MUST succeed (control), got ${code(ok)}`)).toBe(200);
|
|
278
|
+
} finally {
|
|
279
|
+
await done(runId);
|
|
280
|
+
if (controlRun) await done(controlRun);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -41,7 +41,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
41
41
|
import { req } from '../lib/requirement-ids.js';
|
|
42
42
|
|
|
43
43
|
const ID = 'openwop.requirement.0168.advertised-fixtures-exist';
|
|
44
|
-
const DOC = '
|
|
44
|
+
const DOC = 'RFC 0168 §C.3';
|
|
45
45
|
/** Creating one run per advertised fixture would be a load test, not a check. */
|
|
46
46
|
const SAMPLE = 5;
|
|
47
47
|
|
|
@@ -58,7 +58,7 @@ function advertisedIds(doc: Record<string, unknown>): string[] {
|
|
|
58
58
|
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : [];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
describe('v2-advertised-fixtures-exist (
|
|
61
|
+
describe('v2-advertised-fixtures-exist (RFC 0168 §C.3)', () => {
|
|
62
62
|
it('a sampled advertised fixture is actually creatable, not just listed', async () => {
|
|
63
63
|
const doc = await v2Discovery().catch(() => null);
|
|
64
64
|
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-artifact-a2a-shape — RFC 0205 §A, the behavioural legs (suite 2.36.0,
|
|
3
|
+
* target major 2).
|
|
4
|
+
*
|
|
5
|
+
* `getArtifact` (`GET /runs/{runId}/artifacts/{artifactId}`) MAY answer an A2A
|
|
6
|
+
* `Artifact` when the client's `Accept` prefers `application/a2a+json`. The
|
|
7
|
+
* discriminator is the response `Content-Type`: a `200` served as
|
|
8
|
+
* `application/a2a+json` MUST validate against `schemas/v2/artifact.schema.json`,
|
|
9
|
+
* its `artifactId` MUST equal the path segment, and a `url` Part in it MUST NOT
|
|
10
|
+
* resolve beyond the caller's `artifacts:read` authorization.
|
|
11
|
+
*
|
|
12
|
+
* The suite needs an artifact it can name, so the legs run inside the
|
|
13
|
+
* `conformance-artifact-emit` fixture (one node that produces one artifact and
|
|
14
|
+
* emits `artifact.created`). Gates, decided before any assertion so a skipped
|
|
15
|
+
* leg never records a partial witness:
|
|
16
|
+
* - the fixture is not advertised ⇒ `inapplicable`;
|
|
17
|
+
* - the host answers `application/json` (it MAY; SHOULD offer the A2A shape)
|
|
18
|
+
* ⇒ `inapplicable`, never a pass;
|
|
19
|
+
* - the `url` leg: no `url` Part in the body ⇒ `inapplicable`; a `url` Part
|
|
20
|
+
* but no `OPENWOP_TEST_TENANT_B_API_KEY` ⇒ `blocked`.
|
|
21
|
+
* The server-free legs (the schemas against A2A v1.0.1) are corpus rows in
|
|
22
|
+
* `src/coherence/a2a-parts-schemas.test.ts`.
|
|
23
|
+
*
|
|
24
|
+
* @see RFCS/0205-run-artifacts-and-turns-speak-a2a-parts.md
|
|
25
|
+
* @see spec/v2/core/runs.md §"Annotations, artifacts, eval summary"
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { driver } from '../lib/driver.js';
|
|
30
|
+
import { loadEnv } from '../lib/env.js';
|
|
31
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
32
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
33
|
+
import { req } from '../lib/requirement-ids.js';
|
|
34
|
+
|
|
35
|
+
type Json = Record<string, unknown>;
|
|
36
|
+
const FIXTURE = 'conformance-artifact-emit';
|
|
37
|
+
const A2A = 'application/a2a+json';
|
|
38
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
39
|
+
|
|
40
|
+
async function waitTerminal(runId: string, timeoutMs: number): Promise<string | null> {
|
|
41
|
+
const deadline = Date.now() + timeoutMs;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
44
|
+
const status = res.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
45
|
+
if (status !== null && TERMINAL.has(status)) return status;
|
|
46
|
+
if (Date.now() > deadline) return status;
|
|
47
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Read = { runId: string; artifactId: string; status: number; contentType: string; body: unknown };
|
|
52
|
+
type Gate = { ok: true; read: Read } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
|
|
53
|
+
|
|
54
|
+
let memo: Promise<Gate> | undefined;
|
|
55
|
+
/** One run, one artifact, one negotiated read — shared by both legs. */
|
|
56
|
+
function negotiatedRead(): Promise<Gate> {
|
|
57
|
+
memo ??= (async (): Promise<Gate> => {
|
|
58
|
+
const doc = await v2Discovery();
|
|
59
|
+
if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
60
|
+
const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
|
|
61
|
+
if (!fixtures.includes(FIXTURE)) return { ok: false, kind: 'inapplicable', reason: `no artifact-producing fixture: the host does not advertise ${FIXTURE} (RFC 0205 register G2)` };
|
|
62
|
+
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
63
|
+
if (create.status !== 201) return { ok: false, kind: 'blocked', reason: `POST /runs {workflowId: ${FIXTURE}} answered ${create.status}` };
|
|
64
|
+
const runId = String((create.json as { runId?: unknown } | null)?.runId ?? '');
|
|
65
|
+
const status = await waitTerminal(runId, 15_000);
|
|
66
|
+
if (status !== 'completed') return { ok: false, kind: 'blocked', reason: `${FIXTURE} did not complete (status ${status})` };
|
|
67
|
+
const poll = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
68
|
+
const events = ((poll.json as { events?: unknown } | null)?.events ?? []) as Json[];
|
|
69
|
+
const created = Array.isArray(events) ? events.find((e) => e['type'] === 'artifact.created') : undefined;
|
|
70
|
+
const artifactId = String(((created?.['payload'] ?? {}) as Json)['artifactId'] ?? '');
|
|
71
|
+
if (!artifactId) return { ok: false, kind: 'blocked', reason: `${FIXTURE} completed without an artifact.created event naming an artifactId` };
|
|
72
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(artifactId)}`, { headers: { Accept: `${A2A}, application/json;q=0.5` } });
|
|
73
|
+
const contentType = (res.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
|
|
74
|
+
return { ok: true, read: { runId, artifactId, status: res.status, contentType, body: res.json } };
|
|
75
|
+
})();
|
|
76
|
+
return memo;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('RFC 0205 §A.2 — an application/a2a+json artifact read is an A2A Artifact', () => {
|
|
80
|
+
it('a getArtifact answered as application/a2a+json validates against artifact.schema.json', async () => {
|
|
81
|
+
const g = await negotiatedRead();
|
|
82
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
83
|
+
const { status, contentType, body } = g.read;
|
|
84
|
+
if (status === 404 || status === 405) return softSkip('inapplicable', `getArtifact answered ${status} for the artifact the fixture announced — the read is not implemented`);
|
|
85
|
+
if (status === 200 && contentType === 'application/json') return softSkip('inapplicable', 'host does not serve application/a2a+json (SHOULD); it answered application/json, whose shape is implementation-defined');
|
|
86
|
+
expect(status, req('openwop.requirement.0205.artifact-a2a-shape', 'spec/v2/core/runs.md', `getArtifact for an artifact the run announced MUST answer 200 (got ${status}, ${contentType})`)).toBe(200);
|
|
87
|
+
expect(contentType, req('openwop.requirement.0205.artifact-a2a-shape', 'RFC 0205 §A.1', `a 200 is application/json or ${A2A}; got ${contentType}`)).toBe(A2A);
|
|
88
|
+
const r = v2Validator('artifact')(body);
|
|
89
|
+
expect(r.ok, req('openwop.requirement.0205.artifact-a2a-shape', 'RFC 0205 §A.2', `a body served as ${A2A} MUST validate against schemas/v2/artifact.schema.json: ${r.errors}`)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('the artifactId of an application/a2a+json body equals the {artifactId} path segment', async () => {
|
|
93
|
+
const g = await negotiatedRead();
|
|
94
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
95
|
+
const { artifactId, status, contentType, body } = g.read;
|
|
96
|
+
if (status !== 200 || contentType !== A2A) return softSkip('inapplicable', `no ${A2A} artifact body to compare (status ${status}, ${contentType || 'no content type'}); the host does not serve the A2A shape (SHOULD)`);
|
|
97
|
+
expect(body !== null && typeof body === 'object', req('openwop.requirement.0205.artifact-id-matches-path', 'RFC 0205 §A.2', 'an application/a2a+json body is a JSON object')).toBe(true);
|
|
98
|
+
expect((body as Json)['artifactId'], req('openwop.requirement.0205.artifact-id-matches-path', 'RFC 0205 §A.2', 'the body artifactId MUST equal the {artifactId} path segment')).toBe(artifactId);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('RFC 0205 §A.3 — a url Part does not resolve beyond the caller\'s authorization', () => {
|
|
103
|
+
it('the url of a url Part answers no 2xx to a second tenant or to no credential', async () => {
|
|
104
|
+
const g = await negotiatedRead();
|
|
105
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
106
|
+
const { status, contentType, body } = g.read;
|
|
107
|
+
if (status !== 200 || contentType !== A2A) return softSkip('inapplicable', `no ${A2A} artifact body to read a url Part from (status ${status}, ${contentType || 'no content type'})`);
|
|
108
|
+
const parts = Array.isArray((body as Json)['parts']) ? ((body as Json)['parts'] as Json[]) : [];
|
|
109
|
+
const urls = parts.map((p) => p['url']).filter((u): u is string => typeof u === 'string');
|
|
110
|
+
if (urls.length === 0) return softSkip('inapplicable', 'the artifact carries no url Part (the host emits data/text/raw Parts only; RFC 0205 register G3)');
|
|
111
|
+
const tenantB = process.env['OPENWOP_TEST_TENANT_B_API_KEY'];
|
|
112
|
+
if (!tenantB) return softSkip('blocked', 'the artifact carries a url Part but OPENWOP_TEST_TENANT_B_API_KEY is not supplied — the second-tenant leg cannot run');
|
|
113
|
+
const base = loadEnv().baseUrl;
|
|
114
|
+
for (const u of urls) {
|
|
115
|
+
const abs = new URL(u, base).toString();
|
|
116
|
+
const asB = await fetch(abs, { headers: { Authorization: `Bearer ${tenantB}` }, redirect: 'manual' });
|
|
117
|
+
expect(asB.status >= 200 && asB.status < 300, req('openwop.requirement.0205.artifact-url-part-scoped', 'RFC 0205 §A.3', `${abs} MUST NOT answer 2xx to tenant B's credential (got ${asB.status})`)).toBe(false);
|
|
118
|
+
const anon = await fetch(abs, { redirect: 'manual' });
|
|
119
|
+
expect(anon.status >= 200 && anon.status < 300, req('openwop.requirement.0205.artifact-url-part-scoped', 'RFC 0205 §A.3', `${abs} MUST NOT answer 2xx with no credential (got ${anon.status})`)).toBe(false);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0200 §B.1 — the `WWW-Authenticate` challenge on a refusal (suite 2.36.0, target
|
|
3
|
+
* major 2; gated on an advertised `oauth2`/`oidc` lane).
|
|
4
|
+
*
|
|
5
|
+
* HTTP itself has always required it ("The server generating a 401 response MUST send a
|
|
6
|
+
* WWW-Authenticate header field", RFC 9110 §15.5.2) and no OpenWOP host sent one. §B.1
|
|
7
|
+
* makes it a MUST on a host bound by §A, and pins the two things a generic client needs
|
|
8
|
+
* that a bare `Bearer` does not give: where the metadata is, and — on a scope refusal —
|
|
9
|
+
* EVERY scope the operation requires, because `auth.md` §Scopes says `runs:cancel` does
|
|
10
|
+
* not imply `runs:read`, so a single-scope hint can send a client to ask for the wrong
|
|
11
|
+
* grant.
|
|
12
|
+
*
|
|
13
|
+
* Two legs, one requirement id each:
|
|
14
|
+
*
|
|
15
|
+
* 1. `challenge-401` — a no-credential read carries `Bearer` with `resource_metadata`
|
|
16
|
+
* equal to the §A.2 URL and NO `error` code (RFC 6750 §3.1: an error code is for a
|
|
17
|
+
* credential that was presented and refused); a garbage bearer carries
|
|
18
|
+
* `error="invalid_token"`.
|
|
19
|
+
* 2. `challenge-403-scope` — harness-gated on `OPENWOP_TEST_LOW_SCOPE_KEY`: an
|
|
20
|
+
* operation that key lacks scope for answers `403` with
|
|
21
|
+
* `error="insufficient_scope"` and a `scope` parameter containing the scope the
|
|
22
|
+
* operation requires.
|
|
23
|
+
*
|
|
24
|
+
* How each FAILS (the sabotage run before citing a row):
|
|
25
|
+
* (a) the host sends no WWW-Authenticate → leg 1
|
|
26
|
+
* (b) the host adds error="invalid_token" when NO credential was sent → leg 1
|
|
27
|
+
* (c) resource_metadata points at the root on a sub-path host → leg 1
|
|
28
|
+
* (d) the 403 omits `scope`, or names only one of two → leg 2
|
|
29
|
+
*
|
|
30
|
+
* @see spec/v2/core/identity.md §2.5
|
|
31
|
+
* @see RFCS/0200-host-as-oauth-protected-resource.md §B
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { randomBytes } from 'node:crypto';
|
|
36
|
+
import { driver } from '../lib/driver.js';
|
|
37
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
38
|
+
import { req } from '../lib/requirement-ids.js';
|
|
39
|
+
import { parseChallenge, prmGate } from '../lib/protected-resource.js';
|
|
40
|
+
import { projectBoundId } from '../lib/bound-id.js';
|
|
41
|
+
|
|
42
|
+
const DOC = 'spec/v2/core/identity.md §2.5 (RFC 0200 §B.1)';
|
|
43
|
+
const C401 = 'openwop.requirement.0200.challenge-401';
|
|
44
|
+
const C403 = 'openwop.requirement.0200.challenge-403-scope';
|
|
45
|
+
|
|
46
|
+
/** A tenant-bound id the host certainly did not mint, so the read is a refusal about the CREDENTIAL, not the resource. */
|
|
47
|
+
function unknownRunId(tenant: string): string {
|
|
48
|
+
return projectBoundId(`${tenant}/${randomBytes(12).toString('hex')}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('RFC 0200 §B — v2-auth-challenge (gated on an advertised oauth2/oidc lane)', () => {
|
|
52
|
+
it('a 401 names Bearer and the metadata, and carries an error code only when a credential was presented', async () => {
|
|
53
|
+
const g = await prmGate();
|
|
54
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
55
|
+
const tenant = String((g.doc['host'] as { tenant?: unknown } | undefined)?.tenant ?? 'unknown-tenant');
|
|
56
|
+
|
|
57
|
+
const none = await driver.get(`/runs/${unknownRunId(tenant)}`, { authenticated: false });
|
|
58
|
+
expect(
|
|
59
|
+
none.status,
|
|
60
|
+
req(C401, `${DOC}`, `a request with no credential MUST be refused 401 before the resource is looked up (got ${none.status})`),
|
|
61
|
+
).toBe(401);
|
|
62
|
+
const c1 = parseChallenge(none.headers.get('www-authenticate'));
|
|
63
|
+
expect(
|
|
64
|
+
c1?.scheme,
|
|
65
|
+
req(C401, `${DOC}`, `a 401 MUST carry WWW-Authenticate naming Bearer (RFC 9110 §15.5.2; got ${none.headers.get('www-authenticate') ?? 'no header'})`),
|
|
66
|
+
).toBe('bearer');
|
|
67
|
+
expect(
|
|
68
|
+
c1?.params['resource_metadata'],
|
|
69
|
+
req(C401, `${DOC}`, `the challenge MUST carry resource_metadata naming the §A.2 URL, so a generic OAuth client can discover the authorization server from the refusal alone`),
|
|
70
|
+
).toBe(g.prmUrl);
|
|
71
|
+
expect(
|
|
72
|
+
c1?.params['error'],
|
|
73
|
+
req(C401, `${DOC} (RFC 6750 §3.1)`, `a 401 for a request that presented NO credential MUST NOT carry an error code — an error code describes a credential that was refused, and inventing one tells a client its (absent) token was rejected`),
|
|
74
|
+
).toBeUndefined();
|
|
75
|
+
|
|
76
|
+
const bad = await driver.get(`/runs/${unknownRunId(tenant)}`, { authenticated: false, headers: { Authorization: `Bearer not-a-real-credential-${randomBytes(8).toString('hex')}` } });
|
|
77
|
+
expect(
|
|
78
|
+
bad.status,
|
|
79
|
+
req(C401, `${DOC}`, `a garbage bearer MUST be refused 401 (got ${bad.status})`),
|
|
80
|
+
).toBe(401);
|
|
81
|
+
const c2 = parseChallenge(bad.headers.get('www-authenticate'));
|
|
82
|
+
expect(
|
|
83
|
+
c2?.params['error'],
|
|
84
|
+
req(C401, `${DOC} (RFC 6750 §3.1)`, `a 401 for a credential that WAS presented and refused MUST carry error="invalid_token" (got ${bad.headers.get('www-authenticate') ?? 'no header'})`),
|
|
85
|
+
).toBe('invalid_token');
|
|
86
|
+
expect(
|
|
87
|
+
c2?.params['resource_metadata'],
|
|
88
|
+
req(C401, `${DOC}`, 'the invalid_token challenge MUST also carry resource_metadata'),
|
|
89
|
+
).toBe(g.prmUrl);
|
|
90
|
+
}, 30_000);
|
|
91
|
+
|
|
92
|
+
it('a 403 for insufficient scope names every scope the operation requires', async () => {
|
|
93
|
+
const g = await prmGate();
|
|
94
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
95
|
+
const low = process.env['OPENWOP_TEST_LOW_SCOPE_KEY']?.trim();
|
|
96
|
+
if (!low) {
|
|
97
|
+
return softSkip('blocked', 'OPENWOP_TEST_LOW_SCOPE_KEY is not set — a scope 403 needs a credential that LACKS a scope, and the suite will not infer one; without it the insufficient_scope clause never runs');
|
|
98
|
+
}
|
|
99
|
+
const auth = { authenticated: false as const, headers: { Authorization: `Bearer ${low}` } };
|
|
100
|
+
const res = await driver.post('/runs', { workflowId: 'conformance-noop' }, auth);
|
|
101
|
+
if (res.status === 401) {
|
|
102
|
+
return softSkip('blocked', 'OPENWOP_TEST_LOW_SCOPE_KEY did not authenticate (401) — it must be a valid credential that merely lacks runs:create');
|
|
103
|
+
}
|
|
104
|
+
expect(
|
|
105
|
+
res.status,
|
|
106
|
+
req(C403, `${DOC}`, `a credential that lacks runs:create MUST be refused 403, not 401 and not 201 (got ${res.status}) — OPENWOP_TEST_LOW_SCOPE_KEY names a key without it`),
|
|
107
|
+
).toBe(403);
|
|
108
|
+
const c = parseChallenge(res.headers.get('www-authenticate'));
|
|
109
|
+
expect(
|
|
110
|
+
c?.params['error'],
|
|
111
|
+
req(C403, `${DOC}`, `a 403 for insufficient scope MUST carry error="insufficient_scope" (got ${res.headers.get('www-authenticate') ?? 'no header'})`),
|
|
112
|
+
).toBe('insufficient_scope');
|
|
113
|
+
const scopes = (c?.params['scope'] ?? '').split(/\s+/).filter((s) => s.length > 0);
|
|
114
|
+
expect(
|
|
115
|
+
scopes.includes('runs:create'),
|
|
116
|
+
req(C403, `${DOC}`, `the challenge MUST carry \`scope\` listing EVERY scope the operation requires, space-delimited — runs:create for POST /runs (got "${c?.params['scope'] ?? ''}"); a partial list sends the client to request the wrong grant, because runs:cancel does not imply runs:read`),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
}, 30_000);
|
|
119
|
+
});
|
|
@@ -140,7 +140,7 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
140
140
|
const reg = await http(() => driver.post('/webhooks', { url: registration.url, events: ['run.completed'] }));
|
|
141
141
|
if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
|
|
142
142
|
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
|
|
143
|
-
return softSkip('blocked', `host SSRF guard rejected the suite receiver ${registration.url} (webhooks.md §
|
|
143
|
+
return softSkip('blocked', `host SSRF guard rejected the suite receiver ${registration.url} (webhooks.md §Egress requires it) — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front for the receiver to witness what the host emits`);
|
|
144
144
|
}
|
|
145
145
|
const minted = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
146
146
|
if (reg.status !== 201 || typeof minted !== 'string') return softSkip('blocked', `POST /webhooks answered ${reg.status} without a webhookId — the mint leg above owns that obligation; this leg needs an id to compare`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RFC 0196 §A.3 — a host that advertises `interrupt.callbackDelivery` holds
|
|
3
|
-
* `createRun.callbackUrl` to the `webhooks.md` §
|
|
3
|
+
* `createRun.callbackUrl` to the `webhooks.md` §Egress guard at create time
|
|
4
4
|
* (`spec/v2/core/interrupt.md` §Callback delivery). Target major 2.
|
|
5
5
|
*
|
|
6
6
|
* `callbackUrl` is an outbound request to a URL the CALLER chooses — the
|
|
@@ -47,7 +47,7 @@ describe('v2 callbackUrl egress guard (RFC 0196 §A.3)', () => {
|
|
|
47
47
|
const field = (r.json as { details?: { field?: unknown } } | null)?.details?.field;
|
|
48
48
|
if (r.status !== 400 || readErrorCode(r.json) !== 'validation_error' || field !== 'callbackUrl') wrong.push(`${p.cls}: ${r.status} ${readErrorCode(r.json) ?? ''} field=${String(field)}`);
|
|
49
49
|
}
|
|
50
|
-
expect(accepted, req(ID, DOC, 'a host advertising interrupt.callbackDelivery MUST refuse at createRun a callbackUrl the webhooks.md §
|
|
50
|
+
expect(accepted, req(ID, DOC, 'a host advertising interrupt.callbackDelivery MUST refuse at createRun a callbackUrl the webhooks.md §Egress registration guard would refuse')).toEqual([]);
|
|
51
51
|
expect(wrong, req(ID, DOC, 'the refusal MUST be 400 validation_error naming details.field "callbackUrl"')).toEqual([]);
|
|
52
52
|
});
|
|
53
53
|
});
|