@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,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `capability-maturity-bounded` (RFC 0197 §C.7;
|
|
3
|
+
* `spec/v2/core/capabilities.md` §8, the `technical` row).
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. `capabilities.md` §8 sourced the
|
|
6
|
+
* technical maturity axis from "the record's `status`", which is the HOST's
|
|
7
|
+
* claim about itself. `spec/v2/declaration.json` is the CORPUS's claim, and it
|
|
8
|
+
* calls 82 of 87 families `experimental`. Nothing compared the two, so a host
|
|
9
|
+
* could advertise `status: "stable"` for a family the protocol has not
|
|
10
|
+
* committed to and every gate stayed green. RFC 0197 §C.7 makes the
|
|
11
|
+
* declaration the upper bound: a host MUST NOT advertise `stable` for a family
|
|
12
|
+
* whose declaration row is not `technical: "stable"`. Understating is
|
|
13
|
+
* permitted — a host may call its own offer `experimental` under a `stable`
|
|
14
|
+
* corpus row, because it is understating, and this row is deliberately
|
|
15
|
+
* ONE-SIDED so that it never pushes a host to overclaim.
|
|
16
|
+
*
|
|
17
|
+
* The declaration is read from the corpus the suite ships with
|
|
18
|
+
* (`@openwop/spec-artifacts` in the published layout, `spec/v2/` in a repo
|
|
19
|
+
* checkout — `lib/paths.ts` `SPEC_V2_DIR`), so a consumer installing the
|
|
20
|
+
* tarball measures against the same declaration a contributor does.
|
|
21
|
+
*
|
|
22
|
+
* A key with a record but no declaration row is NOT this row's concern —
|
|
23
|
+
* `v2-capabilities-root-closed` fails an unregistered root key — but it is
|
|
24
|
+
* counted and named rather than skipped in silence.
|
|
25
|
+
*
|
|
26
|
+
* This row CAN fail: run the driver against MyndHyve's committed discovery
|
|
27
|
+
* document and 38 families fail. That run is the sabotage proof, recorded in
|
|
28
|
+
* the RFC 0197 gates PR.
|
|
29
|
+
*
|
|
30
|
+
* @see spec/v2/core/capabilities.md §8
|
|
31
|
+
* @see spec/v2/declaration.json
|
|
32
|
+
* @see RFCS/0197-v2-surfaces-retired-never-reshaped.md §C
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, expect } from 'vitest';
|
|
36
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
39
|
+
import { SCHEMAS_DIR, SPEC_V2_DIR } from '../lib/paths.js';
|
|
40
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
41
|
+
import { req } from '../lib/requirement-ids.js';
|
|
42
|
+
|
|
43
|
+
const DOC = 'spec/v2/core/capabilities.md §8';
|
|
44
|
+
const ID = 'openwop.requirement.0197.maturity-not-overstated';
|
|
45
|
+
|
|
46
|
+
interface DeclarationFamily { key: string; maturity?: { technical?: string } }
|
|
47
|
+
|
|
48
|
+
/** Root keys the generated capabilities schema declares as family records. */
|
|
49
|
+
function familyKeys(): Set<string> {
|
|
50
|
+
const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'capabilities.schema.json'), 'utf8')) as { properties?: Record<string, { required?: string[] }> };
|
|
51
|
+
const out = new Set<string>();
|
|
52
|
+
for (const [key, prop] of Object.entries(schema.properties ?? {})) {
|
|
53
|
+
if (['status', 'since', 'witness'].every((r) => (prop.required ?? []).includes(r))) out.add(key);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function declarationTechnical(): Map<string, string> | null {
|
|
59
|
+
if (SPEC_V2_DIR === null) return null;
|
|
60
|
+
const path = join(SPEC_V2_DIR, 'declaration.json');
|
|
61
|
+
if (!existsSync(path)) return null;
|
|
62
|
+
const decl = JSON.parse(readFileSync(path, 'utf8')) as { families?: DeclarationFamily[] };
|
|
63
|
+
const out = new Map<string, string>();
|
|
64
|
+
for (const f of decl.families ?? []) if (f.maturity?.technical) out.set(f.key, f.maturity.technical);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('v2 capability-maturity-bounded (RFC 0197 §C.7)', () => {
|
|
69
|
+
it('no family record advertises `stable` above its corpus declaration row', async () => {
|
|
70
|
+
const doc = await v2Discovery().catch(() => null);
|
|
71
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
72
|
+
const technical = declarationTechnical();
|
|
73
|
+
if (technical === null) return softSkip('blocked', 'spec/v2/declaration.json is not present in this layout — the corpus bound cannot be read, and a bound that cannot be read is not a pass');
|
|
74
|
+
const keys = familyKeys();
|
|
75
|
+
const records = Object.entries(doc).filter(([k, v]) => keys.has(k) && v !== null && typeof v === 'object' && !Array.isArray(v)) as Array<[string, Record<string, unknown>]>;
|
|
76
|
+
// A row that cannot fail is not a witness: a host advertising no family
|
|
77
|
+
// record reports `blocked`, never a zero-assertion pass.
|
|
78
|
+
if (records.length === 0) return softSkip('blocked', 'the host advertises no family record at the v2 root, so this row would assert nothing — it is blocked, not passing');
|
|
79
|
+
const undeclared: string[] = [];
|
|
80
|
+
const overstated: string[] = [];
|
|
81
|
+
for (const [key, rec] of records) {
|
|
82
|
+
const corpus = technical.get(key);
|
|
83
|
+
// A key with a record but no declaration row is not this row's concern —
|
|
84
|
+
// `v2-capabilities-root-closed` owns an unregistered root key — but it is
|
|
85
|
+
// named rather than skipped in silence.
|
|
86
|
+
if (corpus === undefined) { undeclared.push(key); continue; }
|
|
87
|
+
if (rec['status'] === 'stable' && corpus !== 'stable') overstated.push(`${key} (corpus: ${corpus})`);
|
|
88
|
+
}
|
|
89
|
+
// The SUMMARY assertion comes first on purpose. A per-record assertion that
|
|
90
|
+
// failed first would abort the test at family one and report a single name,
|
|
91
|
+
// when what the host operator needs is the whole list in one message.
|
|
92
|
+
expect(
|
|
93
|
+
overstated,
|
|
94
|
+
req(ID, DOC, `${overstated.length} family record(s) advertise status "stable" above the corpus declaration row. RFC 0197 §C.7 makes the declaration the upper bound — re-cut each with status "experimental" and a future \`until\`, or promote the family by RFC (§C.9). Understating is always permitted, so this bound can never push a host to overclaim. Overstated: ${overstated.join(', ') || 'none'}`),
|
|
95
|
+
).toEqual([]);
|
|
96
|
+
// Then one assertion per record, so the ledger's assertion count equals the
|
|
97
|
+
// record count rather than one: a row that asserted once over 42 records
|
|
98
|
+
// would look identical to a row that asserted over none.
|
|
99
|
+
for (const [key, rec] of records) {
|
|
100
|
+
const corpus = technical.get(key);
|
|
101
|
+
expect(
|
|
102
|
+
corpus === undefined || !(rec['status'] === 'stable' && corpus !== 'stable'),
|
|
103
|
+
req(ID, DOC, `${key}: status ${JSON.stringify(rec['status'])} against corpus maturity ${corpus ?? '(not declared)'}`),
|
|
104
|
+
).toBe(true);
|
|
105
|
+
}
|
|
106
|
+
expect(records.length, req(ID, DOC, `the scenario asserted over ${records.length} family record(s)${undeclared.length > 0 ? `; ${undeclared.length} carry no declaration row and are left to v2-capabilities-root-closed: ${undeclared.join(', ')}` : ''}`)).toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -19,7 +19,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
|
|
|
19
19
|
import { softSkip } from '../lib/soft-skip.js';
|
|
20
20
|
import { req } from '../lib/requirement-ids.js';
|
|
21
21
|
|
|
22
|
-
const DOC = 'spec/v2/core/runs.md §
|
|
22
|
+
const DOC = 'spec/v2/core/runs.md §Run options';
|
|
23
23
|
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
24
24
|
const DOTTED = { version: 1, ai: { 'ai.provider': 'x' } };
|
|
25
25
|
const UNKNOWN = { version: 1, unknownKey: 1 };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-content-locale-keys — RFC 0206 on a live host (target major 2).
|
|
3
|
+
*
|
|
4
|
+
* The corpus half of RFC 0206 (the grammar, the wire agreement, the superset
|
|
5
|
+
* and the two `resolveSection` properties) is witnessed by the coherence test
|
|
6
|
+
* `conformance/src/coherence/locale-key-grammar.test.ts`. This file carries the
|
|
7
|
+
* one row a host has to show: a host that advertises a content locale outside
|
|
8
|
+
* RFC 0103's `ll(-RR)` subset (`es-419`, `zh-Hant`, `fil`) can author content
|
|
9
|
+
* for it and delivers it.
|
|
10
|
+
*
|
|
11
|
+
* The leg runs only when `content.supportedLocales` contains a case-canonical
|
|
12
|
+
* tag (RFC 0206 §A.1) that the RFC 0103 grammar rejected. Otherwise it records
|
|
13
|
+
* `inapplicable` with that reason — never a pass — so the row is accounted for
|
|
14
|
+
* on every v2 cut. No committed bundle advertises such a tag today (register
|
|
15
|
+
* G4), so on every current host the row is `inapplicable`.
|
|
16
|
+
*
|
|
17
|
+
* Flow: create a published page, write the section's base fields
|
|
18
|
+
* (`locale == baseLocale`) and then its overlay for the extended tag (a host on
|
|
19
|
+
* the RFC 0103 grammar answers that write 400 — the leg fails), then
|
|
20
|
+
* `GET /content/pages/{slug}` with `Accept-Language: <tag>` and assert
|
|
21
|
+
* `Content-Language` names the tag (case-insensitively, RFC 5646 §2.1.1) and
|
|
22
|
+
* the section carries the overlay.
|
|
23
|
+
*
|
|
24
|
+
* Sabotage: a host that 400s the extended-tag write, or that serves only the
|
|
25
|
+
* base locale for it, fails the leg (write status / `Content-Language` /
|
|
26
|
+
* overlay assertion respectively).
|
|
27
|
+
*
|
|
28
|
+
* @see RFCS/0206-locale-keys-accept-negotiated-bcp47.md §A.1, §Conformance
|
|
29
|
+
* @see spec/v1/localized-content.md §C, §D (the v2 `content` family's normativeText)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
34
|
+
import { familyAdvertised, v2Discovery } from '../lib/v2.js';
|
|
35
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
36
|
+
import { req } from '../lib/requirement-ids.js';
|
|
37
|
+
import { LOCALE_KEY_PATTERN, LOCALE_KEY_PATTERN_RFC0103 } from '../lib/localized-content.js';
|
|
38
|
+
|
|
39
|
+
const ID = 'openwop.requirement.0206.delivery-extended-locale';
|
|
40
|
+
const DOC = 'RFC 0206 §A.1; localized-content.md §C, §D';
|
|
41
|
+
|
|
42
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
43
|
+
try {
|
|
44
|
+
return await fn();
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('v2-content-locale-keys (RFC 0206)', () => {
|
|
51
|
+
it('an advertised extended content locale is written and delivered', async () => {
|
|
52
|
+
const doc = await v2Discovery().catch(() => null);
|
|
53
|
+
if (doc === null) return softSkip('blocked', 'v2 discovery unreachable');
|
|
54
|
+
const content = await familyAdvertised('content');
|
|
55
|
+
if (content === null || content['supported'] !== true) return softSkip('inapplicable', 'the host does not advertise the content family (no content surface to deliver an extended locale from)');
|
|
56
|
+
const base = typeof content['baseLocale'] === 'string' ? content['baseLocale'] : null;
|
|
57
|
+
const supported = Array.isArray(content['supportedLocales']) ? content['supportedLocales'].filter((x): x is string => typeof x === 'string') : [];
|
|
58
|
+
const keyRe = new RegExp(LOCALE_KEY_PATTERN);
|
|
59
|
+
const oldRe = new RegExp(LOCALE_KEY_PATTERN_RFC0103);
|
|
60
|
+
const tag = supported.find((t) => keyRe.test(t) && !oldRe.test(t));
|
|
61
|
+
if (tag === undefined) return softSkip('inapplicable', `no advertised content locale outside the RFC 0103 subset (content.supportedLocales = ${JSON.stringify(supported)})`);
|
|
62
|
+
if (base === null) return softSkip('blocked', 'content.baseLocale is not advertised, so the base write cannot be addressed');
|
|
63
|
+
|
|
64
|
+
const nonce = Math.random().toString(36).slice(2, 10);
|
|
65
|
+
const pageId = `rfc0206-${nonce}`;
|
|
66
|
+
const slug = `rfc0206-${nonce}`;
|
|
67
|
+
const sectionId = 'hero';
|
|
68
|
+
const overlay = `overlay:${tag}`;
|
|
69
|
+
|
|
70
|
+
const created = await http(() => driver.post('/content/pages', { pageId, slug, name: 'RFC 0206 probe', status: 'published', sectionOrder: [sectionId] }));
|
|
71
|
+
if (created === null || created.status >= 300) return softSkip('blocked', `POST /content/pages answered ${created?.status ?? 'unreachable'}; the probe page could not be created`);
|
|
72
|
+
try {
|
|
73
|
+
const baseWrite = await http(() => driver.put(`/content/pages/${pageId}/sections/${sectionId}`, { locale: base, data: { heading: 'base', cta: 'base-cta' } }));
|
|
74
|
+
if (baseWrite === null || baseWrite.status >= 300) return softSkip('blocked', `the base-locale section write answered ${baseWrite?.status ?? 'unreachable'}`);
|
|
75
|
+
|
|
76
|
+
const tagWrite = await http(() => driver.put(`/content/pages/${pageId}/sections/${sectionId}`, { locale: tag, data: { heading: overlay } }));
|
|
77
|
+
expect(tagWrite?.status, req(ID, DOC, `the admin write with locale "${tag}" (an advertised content locale, §A.1-valid) MUST be accepted — a 400 means the host still validates the RFC 0103 subset`)).toBeLessThan(300);
|
|
78
|
+
|
|
79
|
+
const res = await http(() => driver.get(`/content/pages/${slug}`, { headers: { 'Accept-Language': tag } }));
|
|
80
|
+
if (res === null) return softSkip('blocked', `GET /content/pages/${slug} was unreachable`);
|
|
81
|
+
expect(res.status, req(ID, DOC, `GET /content/pages/${slug} for a published page MUST answer 200`)).toBe(200);
|
|
82
|
+
expect((res.headers.get('content-language') ?? '').toLowerCase(), req(ID, DOC, `Content-Language MUST name the negotiated extended locale "${tag}" (compared case-insensitively)`)).toBe(tag.toLowerCase());
|
|
83
|
+
const sections = (res.json as { sections?: Array<{ sectionId?: string; data?: Record<string, unknown> }> } | undefined)?.sections ?? [];
|
|
84
|
+
const hero = sections.find((s) => s.sectionId === sectionId);
|
|
85
|
+
if (hero === undefined) return softSkip('blocked', `the probe section was not delivered (the §D write alone did not make it servable on this host); the overlay cannot be observed`);
|
|
86
|
+
expect(hero.data?.['heading'], req(ID, DOC, `the section MUST resolve to its "${tag}" overlay`)).toBe(overlay);
|
|
87
|
+
expect(hero.data?.['cta'], req(ID, DOC, 'fields absent from the overlay fall through to base data (shallow merge)')).toBe('base-cta');
|
|
88
|
+
} finally {
|
|
89
|
+
await http(() => driver.delete(`/content/pages/${pageId}`));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-conversation-turn-parts — RFC 0205 §B.5, the behavioural leg (suite 2.36.0,
|
|
3
|
+
* target major 2).
|
|
4
|
+
*
|
|
5
|
+
* A v2 conversation turn MAY carry `parts`, a non-empty array of A2A `Part`s;
|
|
6
|
+
* presence is the discriminator that marks the turn A2A-shaped. This leg reads a
|
|
7
|
+
* live run's `conversation.exchanged` turns and checks every turn that carries
|
|
8
|
+
* `parts` against `schemas/v2/conversation-turn.schema.json`. It mints
|
|
9
|
+
* `openwop.requirement.0205.turn-parts-emitted`, the behavioural twin of the
|
|
10
|
+
* server-free `turn-parts-shape` (a corpus row in
|
|
11
|
+
* `src/coherence/a2a-parts-schemas.test.ts`), so a server-free pass can never
|
|
12
|
+
* stand in for it.
|
|
13
|
+
*
|
|
14
|
+
* Gates, decided before any assertion:
|
|
15
|
+
* - `conversationPrimitive` not advertised, or the
|
|
16
|
+
* `conformance-conversation-lifecycle` fixture not advertised ⇒ `inapplicable`;
|
|
17
|
+
* - the run emits no `conversation.exchanged` turn ⇒ `blocked` (the fixture's
|
|
18
|
+
* own contract is one exchange);
|
|
19
|
+
* - no emitted turn carries `parts` ⇒ `inapplicable` ("host emits no
|
|
20
|
+
* A2A-shaped turns (SHOULD)"), never `executed-pass`.
|
|
21
|
+
*
|
|
22
|
+
* @see RFCS/0205-run-artifacts-and-turns-speak-a2a-parts.md
|
|
23
|
+
* @see spec/v2/core/runs.md §"Conversation and residency capabilities"
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { driver } from '../lib/driver.js';
|
|
28
|
+
import { familyAdvertised, v2Discovery, v2Validator } from '../lib/v2.js';
|
|
29
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
30
|
+
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
|
|
32
|
+
type Json = Record<string, unknown>;
|
|
33
|
+
const FIXTURE = 'conformance-conversation-lifecycle';
|
|
34
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
35
|
+
|
|
36
|
+
async function waitTerminal(runId: string, timeoutMs: number): Promise<string | null> {
|
|
37
|
+
const deadline = Date.now() + timeoutMs;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
40
|
+
const status = res.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
41
|
+
if (status !== null && TERMINAL.has(status)) return status;
|
|
42
|
+
if (Date.now() > deadline) return status;
|
|
43
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('RFC 0205 §B.5 — an emitted turn that carries parts carries a valid Part[]', () => {
|
|
48
|
+
it('every conversation.exchanged turn carrying parts validates against the v2 turn def', async () => {
|
|
49
|
+
const doc = await v2Discovery();
|
|
50
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
51
|
+
if (!(await familyAdvertised('conversationPrimitive'))) return softSkip('inapplicable', 'host does not advertise conversationPrimitive');
|
|
52
|
+
const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
|
|
53
|
+
if (!fixtures.includes(FIXTURE)) return softSkip('inapplicable', `host does not advertise the ${FIXTURE} fixture`);
|
|
54
|
+
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
55
|
+
if (create.status !== 201) return softSkip('blocked', `POST /runs {workflowId: ${FIXTURE}} answered ${create.status}`);
|
|
56
|
+
const runId = String((create.json as { runId?: unknown } | null)?.runId ?? '');
|
|
57
|
+
const status = await waitTerminal(runId, 20_000);
|
|
58
|
+
if (status !== 'completed') return softSkip('blocked', `${FIXTURE} did not complete (status ${status})`);
|
|
59
|
+
const poll = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
60
|
+
const events = ((poll.json as { events?: unknown } | null)?.events ?? []) as Json[];
|
|
61
|
+
const turns = (Array.isArray(events) ? events : [])
|
|
62
|
+
.filter((e) => e['type'] === 'conversation.exchanged')
|
|
63
|
+
.map((e) => ((e['payload'] ?? {}) as Json)['turn'])
|
|
64
|
+
.filter((t): t is Json => t !== null && typeof t === 'object' && !Array.isArray(t));
|
|
65
|
+
if (turns.length === 0) return softSkip('blocked', `${FIXTURE} completed without a conversation.exchanged event carrying a turn`);
|
|
66
|
+
const shaped = turns.filter((t) => 'parts' in t);
|
|
67
|
+
if (shaped.length === 0) return softSkip('inapplicable', 'host emits no A2A-shaped turns (SHOULD)');
|
|
68
|
+
const validate = v2Validator('conversation-turn');
|
|
69
|
+
for (const t of shaped) {
|
|
70
|
+
const r = validate(t);
|
|
71
|
+
expect(r.ok, req('openwop.requirement.0205.turn-parts-emitted', 'RFC 0205 §B.5', `turn ${String(t['messageId'])} carries parts and MUST validate against schemas/v2/conversation-turn.schema.json: ${r.errors}`)).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0199 §C — the `credential` interrupt (`spec/v2/core/oauth.md` §The
|
|
3
|
+
* credential interrupt, `spec/v2/core/interrupt.md` §Payload). Target major 2.
|
|
4
|
+
*
|
|
5
|
+
* Gate: the v2 `oauth` record advertises provider `synthetic` and the fixture
|
|
6
|
+
* `conformance-credential` is in `fixtures[]` (one `conformance.oauth.use` node
|
|
7
|
+
* declaring `auth { type: oauth2, provider: synthetic, scopes: [openwop.read] }`).
|
|
8
|
+
* The facet splits the file:
|
|
9
|
+
* - `oauth.credentialInterrupt` present ⇒ the suspend legs (the node
|
|
10
|
+
* suspends instead of failing; `connectUrl`; the host-side re-check;
|
|
11
|
+
* `declined`; refresh failure raises `reason: expired` after
|
|
12
|
+
* `connector.auth-expired`);
|
|
13
|
+
* - absent ⇒ the RFC 0047 §C.3 regression leg (refresh failure fails the
|
|
14
|
+
* node `connector_auth_expired` and raises no interrupt).
|
|
15
|
+
*
|
|
16
|
+
* Every run belongs to a FRESH Subject from the credential mint seam, so no
|
|
17
|
+
* credential another scenario acquired can satisfy the node (a Subject that
|
|
18
|
+
* already holds one would make the "missing" leg vacuous). No mint seam ⇒
|
|
19
|
+
* `blocked`. The grant legs point `synthetic` at the suite's
|
|
20
|
+
* authorization-server double through `authorize-start` and play the user
|
|
21
|
+
* agent through the host's own `connectUrl` and production callback.
|
|
22
|
+
*
|
|
23
|
+
* @see spec/v2/core/oauth.md §The credential interrupt
|
|
24
|
+
* @see RFCS/0199-outbound-oauth-client-and-credential-interrupt.md §C
|
|
25
|
+
* @see SECURITY/invariants.yaml id: oauth-same-user-binding
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, afterAll } from 'vitest';
|
|
28
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
29
|
+
import { loadEnv } from '../lib/env.js';
|
|
30
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
31
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
import { v2Validator } from '../lib/v2.js';
|
|
34
|
+
import { SEAMS_PREFIX } from '../lib/seams.js';
|
|
35
|
+
import { EXPIRE_REFRESH, authorizeStart, authorizationUrlOf, consent, oauthGate, startAsDouble, userAgentGet, type AsDouble } from '../lib/oauth-as-double.js';
|
|
36
|
+
|
|
37
|
+
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite plays the user agent through the host\'s own connectUrl and callback; the only fixture the host reaches is the suite\'s authorization-server double';
|
|
38
|
+
|
|
39
|
+
const DOC = 'spec/v2/core/oauth.md §The credential interrupt (RFC 0199 §C)';
|
|
40
|
+
const R = (slug: string): string => `openwop.requirement.0199.${slug}`;
|
|
41
|
+
const FIXTURE = 'conformance-credential';
|
|
42
|
+
const NODE = 'use-credential';
|
|
43
|
+
const PROVIDER = 'synthetic';
|
|
44
|
+
const SCOPES = ['openwop.read'];
|
|
45
|
+
const MINT = `${SEAMS_PREFIX}/sample/auth/credential/mint`;
|
|
46
|
+
const validInterrupt = v2Validator('suspend-request');
|
|
47
|
+
|
|
48
|
+
let double: AsDouble | null = null;
|
|
49
|
+
async function as(): Promise<AsDouble> { double ??= await startAsDouble(); return double; }
|
|
50
|
+
afterAll(async () => { await double?.close(); double = null; });
|
|
51
|
+
|
|
52
|
+
const auth = (bearer: string): { authenticated: false; headers: Record<string, string> } => ({ authenticated: false, headers: { Authorization: `Bearer ${bearer}` } });
|
|
53
|
+
|
|
54
|
+
interface Ev { type?: string; sequence?: number; nodeId?: string; payload?: Record<string, unknown> }
|
|
55
|
+
|
|
56
|
+
type Ready = { ok: true; d: AsDouble; facet: boolean; bearer: string } | { ok: false; skip: () => undefined };
|
|
57
|
+
async function ready(): Promise<Ready> {
|
|
58
|
+
const g = await oauthGate();
|
|
59
|
+
if (!g.ok) return { ok: false, skip: () => softSkip(g.kind, g.reason) };
|
|
60
|
+
if (!g.providers.has(PROVIDER)) return { ok: false, skip: () => softSkip('blocked', `oauth.providers does not list ${PROVIDER} — the fixture's provider`) };
|
|
61
|
+
if (!isFixtureAdvertised(FIXTURE)) return { ok: false, skip: () => softSkip('blocked', `fixture ${FIXTURE} is not in the advertised fixtures[]`) };
|
|
62
|
+
const minted = await driver.post(MINT, { lane: 'api-key' }).catch(() => null);
|
|
63
|
+
const bearer = (minted?.json as { credential?: unknown } | undefined)?.credential;
|
|
64
|
+
if (minted === null || minted.status >= 300 || typeof bearer !== 'string') return { ok: false, skip: () => seamAbsent(`${MINT} did not mint a fresh Subject (${minted?.status ?? 'unreachable'}) — a Subject that may already hold a credential would make every leg vacuous`) };
|
|
65
|
+
const d = await as();
|
|
66
|
+
const point = await authorizeStart({ provider: PROVIDER, authUrl: d.authorizeUrl, tokenUrl: d.tokenUrl, issuer: d.issuer, scopes: SCOPES }, bearer);
|
|
67
|
+
if (point.status === 404 || point.status === 405) return { ok: false, skip: () => seamAbsent('POST /conformance/seams/sample/oauth/authorize-start is not mounted — the provider cannot be pointed at the suite\'s double') };
|
|
68
|
+
return { ok: true, d, facet: g.oauth['credentialInterrupt'] === true, bearer };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function createRun(bearer: string): Promise<string> {
|
|
72
|
+
const r = await driver.post('/runs', { workflowId: FIXTURE, inputs: {} }, auth(bearer));
|
|
73
|
+
const runId = (r.json as { runId?: unknown } | undefined)?.runId;
|
|
74
|
+
expect(typeof runId, req(R('credential-interrupt'), 'runs.md createRun', `createRun of ${FIXTURE} MUST be accepted (got ${r.status} ${r.text.slice(0, 200)})`)).toBe('string');
|
|
75
|
+
return runId as string;
|
|
76
|
+
}
|
|
77
|
+
async function status(runId: string, bearer: string): Promise<string | undefined> {
|
|
78
|
+
const r = await driver.get(`/runs/${encodeURIComponent(runId)}`, auth(bearer));
|
|
79
|
+
return r.status === 200 ? (r.json as { status?: string }).status : undefined;
|
|
80
|
+
}
|
|
81
|
+
async function settle(runId: string, bearer: string, want: (s: string | undefined) => boolean, ms = 10_000): Promise<string | undefined> {
|
|
82
|
+
const end = Date.now() + ms;
|
|
83
|
+
let s = await status(runId, bearer);
|
|
84
|
+
while (!want(s) && Date.now() < end) { await new Promise((ok) => setTimeout(ok, 100)); s = await status(runId, bearer); }
|
|
85
|
+
return s;
|
|
86
|
+
}
|
|
87
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
88
|
+
const settledOrSuspended = (s: string | undefined): boolean => s !== undefined && (TERMINAL.has(s) || s.startsWith('waiting-'));
|
|
89
|
+
async function events(runId: string, bearer: string): Promise<Ev[]> {
|
|
90
|
+
const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`, auth(bearer));
|
|
91
|
+
return ((r.json as { events?: Ev[] } | undefined)?.events ?? []);
|
|
92
|
+
}
|
|
93
|
+
const credentialRequest = (evs: Ev[]): Ev | undefined => evs.find((e) => e.type === 'interrupt.requested' && e.payload?.['kind'] === 'credential');
|
|
94
|
+
const resolve = (runId: string, bearer: string, resumeValue: unknown): Promise<OpenWOPResponse> => driver.post(`/runs/${encodeURIComponent(runId)}/interrupts/${NODE}`, { resumeValue }, auth(bearer));
|
|
95
|
+
const errCode = (r: OpenWOPResponse): string | undefined => (r.json as { error?: unknown } | undefined)?.error as string | undefined;
|
|
96
|
+
|
|
97
|
+
/** Run one full grant for `bearer` through authorize-start → the double → the production callback. */
|
|
98
|
+
async function grant(d: AsDouble, bearer: string): Promise<number> {
|
|
99
|
+
const start = await authorizeStart({ provider: PROVIDER, authUrl: d.authorizeUrl, tokenUrl: d.tokenUrl, issuer: d.issuer, scopes: SCOPES }, bearer);
|
|
100
|
+
const url = authorizationUrlOf(start);
|
|
101
|
+
if (url === null) return start.status;
|
|
102
|
+
const cb = await consent(url.toString());
|
|
103
|
+
if (cb === null) return 0;
|
|
104
|
+
return (await userAgentGet(cb, bearer)).status;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('RFC 0199 §C — v2-credential-interrupt (gated on oauth + provider synthetic + fixture conformance-credential)', () => {
|
|
108
|
+
it('a node with no credential suspends on a credential interrupt with a closed CredentialData, status waiting-input', async () => {
|
|
109
|
+
const r = await ready();
|
|
110
|
+
if (!r.ok) return r.skip();
|
|
111
|
+
if (!r.facet) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised — the host keeps RFC 0047 §C.3 (the regression leg below covers it)');
|
|
112
|
+
const id = R('credential-interrupt');
|
|
113
|
+
const runId = await createRun(r.bearer);
|
|
114
|
+
const s = await settle(runId, r.bearer, settledOrSuspended);
|
|
115
|
+
expect(s, req(id, DOC, `a node declaring oauth2 auth with no credential for the Subject MUST suspend instead of failing — snapshot status waiting-input (got ${s})`)).toBe('waiting-input');
|
|
116
|
+
const ev = credentialRequest(await events(runId, r.bearer));
|
|
117
|
+
expect(ev, req(id, `${DOC}; interrupt.md §Payload`, 'the log carries interrupt.requested with kind credential')).toBeDefined();
|
|
118
|
+
const v = validInterrupt(ev!.payload);
|
|
119
|
+
expect(v.ok, req(id, 'schemas/v2/suspend-request.schema.json CredentialData (closed)', `the interrupt payload MUST validate, CredentialData closed: ${v.errors}`)).toBe(true);
|
|
120
|
+
const data = (ev!.payload!['data'] ?? {}) as Record<string, unknown>;
|
|
121
|
+
expect([data['provider'], data['reason']], req(id, DOC, 'data names the provider and reason missing')).toEqual([PROVIDER, 'missing']);
|
|
122
|
+
const connect = new URL(String(data['connectUrl']));
|
|
123
|
+
expect(connect.protocol, req(id, DOC, 'connectUrl MUST be https')).toBe('https:');
|
|
124
|
+
expect(connect.origin, req(id, `${DOC} (connectUrl is host-owned)`, `connectUrl MUST be on the host's own origin (${new URL(loadEnv().baseUrl).origin})`)).toBe(new URL(loadEnv().baseUrl).origin);
|
|
125
|
+
await driver.post(`/runs/${encodeURIComponent(runId)}:cancel`, {}, auth(r.bearer));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('a caller resolve of authorized is refused while no credential resolves, and a resume value cannot carry a credential', async () => {
|
|
129
|
+
const r = await ready();
|
|
130
|
+
if (!r.ok) return r.skip();
|
|
131
|
+
if (!r.facet) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised');
|
|
132
|
+
const id = R('credential-resume-rechecked');
|
|
133
|
+
const runId = await createRun(r.bearer);
|
|
134
|
+
expect(await settle(runId, r.bearer, settledOrSuspended), req(id, DOC, 'precondition: the run suspends on the credential interrupt')).toBe('waiting-input');
|
|
135
|
+
const trusted = await resolve(runId, r.bearer, { outcome: 'authorized' });
|
|
136
|
+
expect([trusted.status, errCode(trusted)], req(id, `${DOC} (the host re-checks; it does not trust the caller)`, `a resolve of authorized with no credential for the Subject MUST be refused 400 validation_error (got ${trusted.status} ${trusted.text.slice(0, 200)})`)).toEqual([400, 'validation_error']);
|
|
137
|
+
const field = ((trusted.json as { details?: { field?: unknown } } | undefined)?.details?.field);
|
|
138
|
+
expect(field, req(id, 'RFC 0199 §C.4', 'the refusal names details.field resumeValue')).toBe('resumeValue');
|
|
139
|
+
const smuggled = await resolve(runId, r.bearer, { outcome: 'authorized', token: 'xoxb-conformance-not-a-credential' });
|
|
140
|
+
expect(smuggled.status, req(id, 'interrupt.md §Payload (the resume value is { outcome } and carries no credential)', `a resume value carrying anything but outcome MUST fail resumeSchema with 400 (got ${smuggled.status})`)).toBe(400);
|
|
141
|
+
expect(await status(runId, r.bearer), req(id, DOC, 'the refused resolves change nothing: the run is still waiting-input')).toBe('waiting-input');
|
|
142
|
+
await driver.post(`/runs/${encodeURIComponent(runId)}:cancel`, {}, auth(r.bearer));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('connectUrl is not pre-authenticated and begins the grant only for the initiating Subject', async () => {
|
|
146
|
+
const r = await ready();
|
|
147
|
+
if (!r.ok) return r.skip();
|
|
148
|
+
if (!r.facet) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised');
|
|
149
|
+
const id = R('credential-interrupt');
|
|
150
|
+
const runId = await createRun(r.bearer);
|
|
151
|
+
expect(await settle(runId, r.bearer, settledOrSuspended), req(id, DOC, 'precondition: the run suspends on the credential interrupt')).toBe('waiting-input');
|
|
152
|
+
const connect = String(((credentialRequest(await events(runId, r.bearer))?.payload?.['data'] ?? {}) as Record<string, unknown>)['connectUrl']);
|
|
153
|
+
const startsGrant = (x: { status: number; location: string | null; text: string }): boolean => (x.location ?? '').startsWith(r.d.authorizeUrl) || x.text.includes(r.d.authorizeUrl);
|
|
154
|
+
const anon = await userAgentGet(connect, null);
|
|
155
|
+
expect(startsGrant(anon), req(id, `${DOC} (connectUrl MUST NOT be pre-authenticated)`, `an unauthenticated GET of connectUrl MUST NOT reach an authorization URL (got ${anon.status} → ${anon.location})`)).toBe(false);
|
|
156
|
+
const minted = await driver.post(MINT, { lane: 'api-key' });
|
|
157
|
+
const other = (minted.json as { credential?: string } | undefined)?.credential;
|
|
158
|
+
if (typeof other === 'string') {
|
|
159
|
+
const foreign = await userAgentGet(connect, other);
|
|
160
|
+
expect(startsGrant(foreign), req(id, `${DOC}; invariant oauth-same-user-binding`, `connectUrl opened by another Subject MUST NOT begin the grant (got ${foreign.status} → ${foreign.location})`)).toBe(false);
|
|
161
|
+
}
|
|
162
|
+
const own = await userAgentGet(connect, r.bearer);
|
|
163
|
+
expect(startsGrant(own), req(id, DOC, `positive control: the initiating Subject opening connectUrl is sent to the provider's authorization endpoint (got ${own.status} → ${own.location})`)).toBe(true);
|
|
164
|
+
expect(/ow2\.[a-z0-9]+\.[^/?#]+\./i.test(decodeURIComponent(connect)), req(id, `${DOC} (connectUrl is not the interrupt's capability token)`, 'connectUrl MUST NOT embed the interrupt token (identity.md §4 grammar ow2.<alg>.<kid>.…)')).toBe(false);
|
|
165
|
+
await driver.post(`/runs/${encodeURIComponent(runId)}:cancel`, {}, auth(r.bearer));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('the grant completes through connectUrl, the host resolves the interrupt itself and the run continues', async () => {
|
|
169
|
+
const r = await ready();
|
|
170
|
+
if (!r.ok) return r.skip();
|
|
171
|
+
if (!r.facet) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised');
|
|
172
|
+
const id = R('credential-interrupt');
|
|
173
|
+
const runId = await createRun(r.bearer);
|
|
174
|
+
expect(await settle(runId, r.bearer, settledOrSuspended), req(id, DOC, 'precondition: the run suspends on the credential interrupt')).toBe('waiting-input');
|
|
175
|
+
const connect = String(((credentialRequest(await events(runId, r.bearer))?.payload?.['data'] ?? {}) as Record<string, unknown>)['connectUrl']);
|
|
176
|
+
const open = await userAgentGet(connect, r.bearer);
|
|
177
|
+
expect(open.location, req(id, DOC, `connectUrl redirects the initiating Subject to the provider (got ${open.status})`)).not.toBeNull();
|
|
178
|
+
const n = r.d.codeExchanges().length;
|
|
179
|
+
const cb = await consent(open.location!);
|
|
180
|
+
const done = await userAgentGet(cb!, r.bearer);
|
|
181
|
+
expect([done.status < 400, r.d.codeExchanges().length], req(id, DOC, `the production callback completes the grant with one token request (got ${done.status} ${done.text.slice(0, 200)})`)).toEqual([true, n + 1]);
|
|
182
|
+
const s = await settle(runId, r.bearer, (x) => x !== undefined && TERMINAL.has(x));
|
|
183
|
+
expect(s, req(id, DOC, `the host resolves the interrupt when the grant completes and the run continues to completion (got ${s})`)).toBe('completed');
|
|
184
|
+
const resolved = (await events(runId, r.bearer)).find((e) => e.type === 'interrupt.resolved');
|
|
185
|
+
expect(resolved?.payload?.['resumeValue'], req(id, 'RFC 0199 §C.4', 'interrupt.resolved records resumeValue { outcome: authorized }')).toEqual({ outcome: 'authorized' });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('declined fails the node with connector_auth_declined', async () => {
|
|
189
|
+
const r = await ready();
|
|
190
|
+
if (!r.ok) return r.skip();
|
|
191
|
+
if (!r.facet) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised');
|
|
192
|
+
const id = R('credential-declined');
|
|
193
|
+
const runId = await createRun(r.bearer);
|
|
194
|
+
expect(await settle(runId, r.bearer, settledOrSuspended), req(id, DOC, 'precondition: the run suspends on the credential interrupt')).toBe('waiting-input');
|
|
195
|
+
const dec = await resolve(runId, r.bearer, { outcome: 'declined' });
|
|
196
|
+
expect(dec.status, req(id, DOC, `a resolve of declined is accepted (got ${dec.status} ${dec.text.slice(0, 200)})`)).toBeLessThan(300);
|
|
197
|
+
const s = await settle(runId, r.bearer, (x) => x !== undefined && TERMINAL.has(x));
|
|
198
|
+
expect(s, req(id, DOC, 'declined fails the node, and the run')).toBe('failed');
|
|
199
|
+
const failed = (await events(runId, r.bearer)).find((e) => e.type === 'node.failed');
|
|
200
|
+
expect((failed?.payload?.['error'] as { code?: string } | undefined)?.code, req(id, `${DOC}; errors.json connector_auth_declined`, 'the node fails with connector_auth_declined')).toBe('connector_auth_declined');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('a terminal refresh failure emits connector.auth-expired, then suspends with reason expired', async () => {
|
|
204
|
+
const r = await ready();
|
|
205
|
+
if (!r.ok) return r.skip();
|
|
206
|
+
if (!r.facet) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised');
|
|
207
|
+
const id = R('credential-interrupt');
|
|
208
|
+
const got = await grant(r.d, r.bearer);
|
|
209
|
+
expect(got, req(id, DOC, `precondition: the Subject acquires a credential through the production grant (callback ${got})`)).toBeLessThan(400);
|
|
210
|
+
const exp = await driver.post(EXPIRE_REFRESH, { provider: PROVIDER }, auth(r.bearer));
|
|
211
|
+
if (exp.status === 404 || exp.status === 405) return seamAbsent('POST /conformance/seams/sample/oauth/expire-refresh is not mounted');
|
|
212
|
+
expect(exp.status, req(id, 'api/seams-v2.yaml expireOAuthAccessToken', `expire-refresh answers 204 (got ${exp.status})`)).toBe(204);
|
|
213
|
+
r.d.refreshRevoked = true;
|
|
214
|
+
try {
|
|
215
|
+
const refreshesBefore = r.d.tokenRequests.filter((t) => t.grantType === 'refresh_token').length;
|
|
216
|
+
const runId = await createRun(r.bearer);
|
|
217
|
+
const s = await settle(runId, r.bearer, settledOrSuspended);
|
|
218
|
+
expect(r.d.tokenRequests.filter((t) => t.grantType === 'refresh_token').length, req(id, 'oauth.md §Token lifecycle', 'the expired token is refreshed host-side at the provider (the double counts the refresh)')).toBeGreaterThan(refreshesBefore);
|
|
219
|
+
expect(s, req(id, DOC, `a terminal refresh failure MUST suspend the node instead of failing it (got ${s})`)).toBe('waiting-input');
|
|
220
|
+
const evs = await events(runId, r.bearer);
|
|
221
|
+
const expired = evs.findIndex((e) => e.type === 'connector.auth-expired');
|
|
222
|
+
const asked = evs.findIndex((e) => e.type === 'interrupt.requested' && e.payload?.['kind'] === 'credential');
|
|
223
|
+
expect(expired >= 0 && asked > expired, req(id, 'RFC 0199 §C.2(b)', `connector.auth-expired MUST precede the credential interrupt (indices ${expired}, ${asked})`)).toBe(true);
|
|
224
|
+
expect(((evs[asked]?.payload?.['data'] ?? {}) as Record<string, unknown>)['reason'], req(id, DOC, 'the interrupt carries reason expired')).toBe('expired');
|
|
225
|
+
await driver.post(`/runs/${encodeURIComponent(runId)}:cancel`, {}, auth(r.bearer));
|
|
226
|
+
} finally {
|
|
227
|
+
r.d.refreshRevoked = false;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('without oauth.credentialInterrupt, a terminal refresh failure fails the node connector_auth_expired and raises no interrupt', async () => {
|
|
232
|
+
const r = await ready();
|
|
233
|
+
if (!r.ok) return r.skip();
|
|
234
|
+
if (r.facet) return softSkip('inapplicable', 'oauth.credentialInterrupt is advertised — the host suspends instead (the legs above)');
|
|
235
|
+
const id = R('refresh-failure-fails-node');
|
|
236
|
+
const got = await grant(r.d, r.bearer);
|
|
237
|
+
expect(got, req(id, DOC, `precondition: the Subject acquires a credential through the production grant (callback ${got})`)).toBeLessThan(400);
|
|
238
|
+
const exp = await driver.post(EXPIRE_REFRESH, { provider: PROVIDER }, auth(r.bearer));
|
|
239
|
+
if (exp.status === 404 || exp.status === 405) return seamAbsent('POST /conformance/seams/sample/oauth/expire-refresh is not mounted');
|
|
240
|
+
r.d.refreshRevoked = true;
|
|
241
|
+
try {
|
|
242
|
+
const runId = await createRun(r.bearer);
|
|
243
|
+
const s = await settle(runId, r.bearer, settledOrSuspended);
|
|
244
|
+
expect(s, req(id, 'oauth.md §Token lifecycle; RFC 0047 §C.3 (unchanged by RFC 0199 C1)', `without the facet a terminal refresh failure MUST fail the node (got ${s})`)).toBe('failed');
|
|
245
|
+
const evs = await events(runId, r.bearer);
|
|
246
|
+
expect(evs.some((e) => e.type === 'connector.auth-expired'), req(id, 'oauth.md §Token lifecycle', 'connector.auth-expired is emitted')).toBe(true);
|
|
247
|
+
expect(evs.some((e) => e.type === 'interrupt.requested'), req(id, 'RFC 0199 §C.1', 'a host that does not advertise the facet MUST NOT raise an interrupt')).toBe(false);
|
|
248
|
+
expect(((evs.find((e) => e.type === 'node.failed')?.payload?.['error']) as { code?: string } | undefined)?.code, req(id, 'oauth.md §Token lifecycle', 'the node fails with connector_auth_expired')).toBe('connector_auth_expired');
|
|
249
|
+
} finally {
|
|
250
|
+
r.d.refreshRevoked = false;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -120,12 +120,12 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
120
120
|
const keys = new Set(attempts.map((e) => String(e['providerKey'] ?? '')));
|
|
121
121
|
expect(
|
|
122
122
|
keys.size,
|
|
123
|
-
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/
|
|
123
|
+
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `every attempt of one effect MUST present the same provider key across a transport retry — ${attempts.length} attempt(s) presented ${keys.size} distinct key(s)`),
|
|
124
124
|
).toBe(1);
|
|
125
125
|
for (const a of attempts) {
|
|
126
126
|
expect(
|
|
127
127
|
a['keying'],
|
|
128
|
-
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/
|
|
128
|
+
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `a Layer-2 host keys a retried effect on business identity, not the activity recipe (attempt ${String(a['attempt'])})`),
|
|
129
129
|
).toBe('business-identity');
|
|
130
130
|
}
|
|
131
131
|
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* output modulo clock fields and freshly minted ids), and `run.started` on the
|
|
9
9
|
* fork MUST carry the legacy Subject (`issuer: urn:openwop:legacy`,
|
|
10
10
|
* identity.md) where the parent had none (`spec/v2/core/replay.md` §Forking a
|
|
11
|
-
* v1 run;
|
|
11
|
+
* v1 run; RFC 0170 §A.3).
|
|
12
12
|
*
|
|
13
13
|
* The era-2 parent is seeded in v1 vocabulary with a `run.started` that
|
|
14
14
|
* carries no `owner` (a v1 payload) through the event-log seed seam
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0208 §G — `implementation` gains an optional `url` (the operator's site:
|
|
3
|
+
* A2A `AgentProvider.url`, MCP `Implementation.websiteUrl`) and stays a closed
|
|
4
|
+
* object (`spec/v2/core/capabilities.md` §3.1). Target major 2; server-free —
|
|
5
|
+
* the leg validates against the packaged `schemas/v2/capabilities.schema.json`
|
|
6
|
+
* and never contacts a host.
|
|
7
|
+
*
|
|
8
|
+
* The §G SHOULD NOT ("a client SHOULD NOT change behaviour because of it or
|
|
9
|
+
* authorize from it") binds consumers and is not witnessable from a host; it is
|
|
10
|
+
* stated in the RFC's falsifiability table, not claimed here.
|
|
11
|
+
*
|
|
12
|
+
* @see spec/v2/core/capabilities.md §3.1
|
|
13
|
+
* @see RFCS/0208-v2-a2a-mcp-operation-mappings.md §G
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { v2Validator } from '../lib/v2.js';
|
|
17
|
+
import { req } from '../lib/requirement-ids.js';
|
|
18
|
+
|
|
19
|
+
export const HOST_CALLBACK_NOT_REQUIRED = 'server-free: validates documents against the packaged v2 capabilities schema';
|
|
20
|
+
|
|
21
|
+
const ID = 'openwop.requirement.0208.implementation-shape';
|
|
22
|
+
const DOC = 'spec/v2/core/capabilities.md §3.1 (RFC 0208 §G)';
|
|
23
|
+
const ROOT = { protocolVersions: ['2.0'], preferredVersion: '2.0' };
|
|
24
|
+
|
|
25
|
+
describe('RFC 0208 §G — v2-implementation-informational (server-free)', () => {
|
|
26
|
+
it('implementation accepts name, version, vendor and url, and still rejects an unknown key', () => {
|
|
27
|
+
const validate = v2Validator('capabilities');
|
|
28
|
+
const good = validate({ ...ROOT, implementation: { name: 'conformance', version: '1.0.0', vendor: 'openwop', url: 'https://example.org' } });
|
|
29
|
+
expect(good.ok, req(ID, DOC, `implementation.url (format uri) MUST validate beside name, version and vendor (${good.errors})`)).toBe(true);
|
|
30
|
+
expect(validate({ ...ROOT, implementation: { name: 'conformance', homepage: 'https://example.org' } }).ok, req(ID, DOC, 'implementation stays closed: an unknown key (homepage) MUST fail validation')).toBe(false);
|
|
31
|
+
expect(validate({ ...ROOT, implementation: { url: 'not a uri' } }).ok, req(ID, DOC, 'implementation.url is format uri: a non-URI MUST fail validation')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|