@openwop/openwop-conformance 2.0.0-rc.39 → 2.0.0-rc.42
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/README.md +2 -2
- package/dist/cli.js +3 -32
- package/dist/lib/requirement-registry.js +59 -0
- package/dist/lib/scenario-disposition.js +13 -0
- package/dist/lib/seams.js +42 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +36 -3
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +7 -7
- package/src/cli.ts +3 -28
- package/src/lib/fixtures.ts +31 -0
- package/src/lib/requirement-registry.ts +57 -0
- package/src/lib/scenario-disposition.ts +11 -0
- package/src/scenarios/v2-malformed-body-envelope.test.ts +79 -0
- package/src/setup.ts +32 -33
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
112
112
|
|
|
113
113
|
## What's Covered
|
|
114
114
|
|
|
115
|
-
The current suite has
|
|
115
|
+
The current suite has 503 scenario files under `src/scenarios/`.
|
|
116
116
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
117
117
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
118
118
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
@@ -457,7 +457,7 @@ Server-required (added in 1.7.0):
|
|
|
457
457
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
458
458
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
459
459
|
|
|
460
|
-
Current source tree:
|
|
460
|
+
Current source tree: 503 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
461
461
|
|
|
462
462
|
## Remaining Gaps
|
|
463
463
|
|
package/dist/cli.js
CHANGED
|
@@ -46,7 +46,7 @@ import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
|
|
|
46
46
|
import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
|
|
47
47
|
import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
|
|
48
48
|
import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
|
|
49
|
-
import { setV2ProfileFloors } from './lib/requirement-registry.js';
|
|
49
|
+
import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
|
|
50
50
|
function parseArgs(argv) {
|
|
51
51
|
let baseUrl;
|
|
52
52
|
let apiKey;
|
|
@@ -354,37 +354,8 @@ function claimedProfilesForV2(doc, conformanceRoot) {
|
|
|
354
354
|
* fail a host for the corpus's own backlog.
|
|
355
355
|
*/
|
|
356
356
|
function v2ProfileFloors(conformanceRoot) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
return {};
|
|
360
|
-
let known;
|
|
361
|
-
try {
|
|
362
|
-
known = new Set(Object.keys(JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8')).majors));
|
|
363
|
-
}
|
|
364
|
-
catch {
|
|
365
|
-
known = new Set();
|
|
366
|
-
}
|
|
367
|
-
let registry;
|
|
368
|
-
try {
|
|
369
|
-
registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
370
|
-
}
|
|
371
|
-
catch {
|
|
372
|
-
return {};
|
|
373
|
-
}
|
|
374
|
-
const out = {};
|
|
375
|
-
for (const p of registry.profiles ?? []) {
|
|
376
|
-
if (typeof p.id !== 'string')
|
|
377
|
-
continue;
|
|
378
|
-
const raw = Array.isArray(p.floorScenarios) ? p.floorScenarios.map(String) : [];
|
|
379
|
-
const files = [];
|
|
380
|
-
for (const entry of raw) {
|
|
381
|
-
const name = entry.startsWith('planned:') ? `v2-${entry.slice('planned:'.length)}.test.ts` : entry.endsWith('.test.ts') ? entry : `${entry}.test.ts`;
|
|
382
|
-
if (known.size === 0 || known.has(name))
|
|
383
|
-
files.push(name);
|
|
384
|
-
}
|
|
385
|
-
out[p.id] = files;
|
|
386
|
-
}
|
|
387
|
-
return out;
|
|
357
|
+
// One derivation, shared with the ledger's floor-file set (requirement-registry.ts).
|
|
358
|
+
return v2ProfileFloorFiles(conformanceRoot);
|
|
388
359
|
}
|
|
389
360
|
/**
|
|
390
361
|
* Reduce a vitest JSON report into a per-scenario-file terminal state, keyed by
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
* resolves an unrecorded requirement to `blocked`.
|
|
22
22
|
*/
|
|
23
23
|
import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
27
|
/** `runs-lifecycle.test.ts` → `openwop.floor.runs-lifecycle`. */
|
|
25
28
|
export function requirementIdForScenario(scenarioFile) {
|
|
26
29
|
return `openwop.floor.${scenarioFile.replace(/\.test\.ts$/, '')}`;
|
|
@@ -128,3 +131,59 @@ export function allRequirements() {
|
|
|
128
131
|
}
|
|
129
132
|
return [...ids].sort();
|
|
130
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The MAJOR-2 floors, derived from `spec/v2/profiles.json` (itself generated
|
|
136
|
+
* from `spec/v2/declaration.json`), keyed by profile id and resolved to
|
|
137
|
+
* scenario file names. A `planned:<stem>` entry names `v2-<stem>.test.ts`; an
|
|
138
|
+
* entry is kept only if `scenario-majors.json` knows the file.
|
|
139
|
+
*
|
|
140
|
+
* This function exists in ONE place on purpose. Until 2026-09-05 the CLI
|
|
141
|
+
* derived these floors privately for `--certify` while the ledger decided
|
|
142
|
+
* "is this file a floor?" from PROFILE_FLOOR_SCENARIOS — the v1 hand table,
|
|
143
|
+
* which knows no v2 file. So a v2 floor file was MINTED as
|
|
144
|
+
* `openwop.scenario.v2-…` and LOOKED UP at certify time as
|
|
145
|
+
* `openwop.floor.v2-…`: 101 executed-pass rows, `witnessCount: 0` on both
|
|
146
|
+
* claimed profiles, and `REJECTING — openwop-discovery-core: unclassified` on
|
|
147
|
+
* a tier-1 host's first production bundle. Two sources of the same fact, one
|
|
148
|
+
* of them stale, and the join between them silent.
|
|
149
|
+
*/
|
|
150
|
+
export function v2ProfileFloorFiles(conformanceRoot) {
|
|
151
|
+
const candidates = [];
|
|
152
|
+
try {
|
|
153
|
+
const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
|
|
154
|
+
candidates.push(resolvePath(dirname(req.resolve('@openwop/spec-artifacts/package.json')), 'spec', 'v2', 'profiles.json'));
|
|
155
|
+
}
|
|
156
|
+
catch { /* not installed as a package; the repo-layout candidates below */ }
|
|
157
|
+
candidates.push(resolvePath(conformanceRoot, 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'));
|
|
158
|
+
const registryPath = candidates.find((c) => existsSync(c));
|
|
159
|
+
if (registryPath === undefined)
|
|
160
|
+
return {};
|
|
161
|
+
let known;
|
|
162
|
+
try {
|
|
163
|
+
known = new Set(Object.keys(JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8')).majors));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
known = new Set();
|
|
167
|
+
}
|
|
168
|
+
let registry;
|
|
169
|
+
try {
|
|
170
|
+
registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return {};
|
|
174
|
+
}
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const p of registry.profiles ?? []) {
|
|
177
|
+
if (typeof p.id !== 'string')
|
|
178
|
+
continue;
|
|
179
|
+
const raw = Array.isArray(p.floorScenarios) ? p.floorScenarios.map(String) : [];
|
|
180
|
+
const files = [];
|
|
181
|
+
for (const entry of raw) {
|
|
182
|
+
const name = entry.startsWith('planned:') ? `v2-${entry.slice('planned:'.length)}.test.ts` : entry.endsWith('.test.ts') ? entry : `${entry}.test.ts`;
|
|
183
|
+
if (known.size === 0 || known.has(name))
|
|
184
|
+
files.push(name);
|
|
185
|
+
}
|
|
186
|
+
out[p.id] = files;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { scenarioFileOfItId } from './requirement-ids.js';
|
|
29
29
|
import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
30
|
+
import { targetMajor } from './seams.js';
|
|
31
|
+
import { PKG_ROOT_PATH } from './paths.js';
|
|
32
|
+
import { v2ProfileFloorFiles } from './requirement-registry.js';
|
|
30
33
|
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
|
|
31
34
|
import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
|
|
32
35
|
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
@@ -41,6 +44,16 @@ export function floorScenarioFiles() {
|
|
|
41
44
|
for (const f of c.required)
|
|
42
45
|
out.add(f);
|
|
43
46
|
}
|
|
47
|
+
// At target major 2 the floors come from the declaration, not the v1 hand
|
|
48
|
+
// table. The ledger and --certify MUST agree on this set, or a floor file is
|
|
49
|
+
// minted `openwop.scenario.*` and looked up as `openwop.floor.*` — which is
|
|
50
|
+
// exactly what refused a tier-1 host's first production bundle over 101
|
|
51
|
+
// executed-pass rows (see v2ProfileFloorFiles).
|
|
52
|
+
if (targetMajor() === 2) {
|
|
53
|
+
for (const files of Object.values(v2ProfileFloorFiles(PKG_ROOT_PATH)))
|
|
54
|
+
for (const f of files)
|
|
55
|
+
out.add(f);
|
|
56
|
+
}
|
|
44
57
|
return out;
|
|
45
58
|
}
|
|
46
59
|
/** The requirement id a scenario FILE records under: the §A floor id when the
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suite 2.0.0 — the seams profile (RFC 0168 §C; `openwop-conformance-seams-v2`).
|
|
3
|
+
*
|
|
4
|
+
* v1 scenarios reach the host-sample seams at `/v1/host/sample/…`,
|
|
5
|
+
* `/v1/host/workspace/files…` and `/v1/packs-test/…`. In v2 those operations
|
|
6
|
+
* leave the canonical API for `api/seams-v2.yaml` under `/conformance/seams/…`
|
|
7
|
+
* (RFC 0168 §C.2). Under target major 2 the driver rewrites the v1 seam path
|
|
8
|
+
* to its v2 address, so the 165 scenarios that name a seam keep working
|
|
9
|
+
* without a sweep; the sweep to `seamPath()` is the P3-F polish.
|
|
10
|
+
*
|
|
11
|
+
* How a v2 host advertises the profile: RFC 0168 §C.1 forbids a `testSeams`
|
|
12
|
+
* capability flag and RFC 0169 §C.1 removes the root `profiles[]`; the
|
|
13
|
+
* reconciliation (recorded against RFC 0168 at its flip) is the
|
|
14
|
+
* `conformance` METADATA key (RFC 0169 §A.1a): `conformance.seamsProfile:
|
|
15
|
+
* "openwop-conformance-seams-v2"`. Metadata, not a capability family.
|
|
16
|
+
*/
|
|
17
|
+
export const SEAMS_PROFILE_ID = 'openwop-conformance-seams-v2';
|
|
18
|
+
export const SEAMS_PREFIX = '/conformance/seams';
|
|
19
|
+
const V1_SEAM_PREFIXES = [
|
|
20
|
+
[/^\/v1\/host\/sample\//, `${SEAMS_PREFIX}/sample/`],
|
|
21
|
+
[/^\/v1\/host\/workspace\/files/, `${SEAMS_PREFIX}/workspace/files`],
|
|
22
|
+
[/^\/v1\/packs-test\//, `${SEAMS_PREFIX}/packs-test/`],
|
|
23
|
+
];
|
|
24
|
+
/** The v2 address of a v1 seam path; a non-seam path is returned unchanged. */
|
|
25
|
+
export function seamPath(v1Path) {
|
|
26
|
+
for (const [re, to] of V1_SEAM_PREFIXES)
|
|
27
|
+
if (re.test(v1Path))
|
|
28
|
+
return v1Path.replace(re, to);
|
|
29
|
+
return v1Path;
|
|
30
|
+
}
|
|
31
|
+
export function isSeamPath(path) {
|
|
32
|
+
return V1_SEAM_PREFIXES.some(([re]) => re.test(path)) || path.startsWith(`${SEAMS_PREFIX}/`);
|
|
33
|
+
}
|
|
34
|
+
/** Whether a (v2) discovery document advertises the seams profile. */
|
|
35
|
+
export function seamsProfileAdvertised(doc) {
|
|
36
|
+
const conf = doc?.['conformance'];
|
|
37
|
+
return typeof conf === 'object' && conf !== null && conf['seamsProfile'] === SEAMS_PROFILE_ID;
|
|
38
|
+
}
|
|
39
|
+
/** The target major the runner selected (RFC 0168 §D.3); 1 when unset. */
|
|
40
|
+
export function targetMajor() {
|
|
41
|
+
return process.env['OPENWOP_TARGET_MAJOR'] === '2' ? 2 : 1;
|
|
42
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "@openwop/spec-artifacts",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
4
|
-
"stampSha256": "
|
|
3
|
+
"version": "2.0.0-rc.42",
|
|
4
|
+
"stampSha256": "092c09c450a58ba8eaebbfb46bfdb5a957c69c1ff4cbe8e3fbc044cbaeccfbbf"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
3
|
+
"version": "2.0.0-rc.42",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.0.0-rc.
|
|
59
|
+
"@openwop/spec-artifacts": "2.0.0-rc.42"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
5
|
+
"files": 550,
|
|
6
|
+
"tests": 2126,
|
|
7
7
|
"withStableId": 2125,
|
|
8
|
-
"interpolatedTitles":
|
|
8
|
+
"interpolatedTitles": 1,
|
|
9
9
|
"explicitIds": 2057
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
@@ -27128,6 +27128,39 @@
|
|
|
27128
27128
|
}
|
|
27129
27129
|
]
|
|
27130
27130
|
},
|
|
27131
|
+
{
|
|
27132
|
+
"id": null,
|
|
27133
|
+
"file": "v2-malformed-body-envelope.test.ts",
|
|
27134
|
+
"line": 53,
|
|
27135
|
+
"title": null,
|
|
27136
|
+
"explicitId": null,
|
|
27137
|
+
"citations": [
|
|
27138
|
+
{
|
|
27139
|
+
"section": null,
|
|
27140
|
+
"requirement": null,
|
|
27141
|
+
"interpolated": true
|
|
27142
|
+
},
|
|
27143
|
+
{
|
|
27144
|
+
"section": "spec/v2/core/errors.md",
|
|
27145
|
+
"requirement": null,
|
|
27146
|
+
"interpolated": true
|
|
27147
|
+
},
|
|
27148
|
+
{
|
|
27149
|
+
"section": "spec/v2/core/errors.md",
|
|
27150
|
+
"requirement": null,
|
|
27151
|
+
"interpolated": true
|
|
27152
|
+
},
|
|
27153
|
+
{
|
|
27154
|
+
"section": "spec/v2/core/errors.md",
|
|
27155
|
+
"requirement": null,
|
|
27156
|
+
"interpolated": true
|
|
27157
|
+
},
|
|
27158
|
+
{
|
|
27159
|
+
"section": "spec/v2/core/errors.md",
|
|
27160
|
+
"requirement": "the envelope's `error` MUST be the registered code for a malformed request body, validation_error"
|
|
27161
|
+
}
|
|
27162
|
+
]
|
|
27163
|
+
},
|
|
27131
27164
|
{
|
|
27132
27165
|
"id": "openwop.it.v2-manifest-ceiling-refused.an-engines-openwop-range-with-no-upper-bound-1-0-0-reads-2-0-0-and-is-refused-at",
|
|
27133
27166
|
"file": "v2-manifest-ceiling-refused.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 503,
|
|
5
5
|
"v1": 445,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 59
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1315,6 +1315,9 @@
|
|
|
1315
1315
|
"v2-legacy-profiles-absent.test.ts": [
|
|
1316
1316
|
2
|
|
1317
1317
|
],
|
|
1318
|
+
"v2-malformed-body-envelope.test.ts": [
|
|
1319
|
+
2
|
|
1320
|
+
],
|
|
1318
1321
|
"v2-manifest-ceiling-refused.test.ts": [
|
|
1319
1322
|
2
|
|
1320
1323
|
],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.0.0-rc.
|
|
4
|
+
"version": "2.0.0-rc.42",
|
|
5
5
|
"corpusTag": null,
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "d32be36aabd37c5e33ebdcb2e88949160e47c5a97b3f21442e9b6f20dde20fe1",
|
|
13
|
+
"api/v2/asyncapi.yaml": "5ada903927b53a4c1db4c5210ef9d4acd1c11cb5c7cc08989ce43d04c7764f35",
|
|
14
|
+
"api/v2/openapi.yaml": "f0a862ea9df59a8cbe45ff818eacce4dd95f0d6f30d38eacd081d3357933f788",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
200
200
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
201
201
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
202
|
-
"spec/v1/core-standard-manifest.json": "
|
|
202
|
+
"spec/v1/core-standard-manifest.json": "56cf132dafcb6e7d9eaa9346d9b0cbc94e0aa06833ca55326a72d6a4ae880365",
|
|
203
203
|
"spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
|
|
204
204
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
205
205
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -230,7 +230,7 @@
|
|
|
230
230
|
"spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
|
|
231
231
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
232
232
|
"spec/v2/profiles.json": "1f0ee40491131cf561ef19a0394c47ba476b4567514fe16befb6cada4a10a5a4",
|
|
233
|
-
"spec/v2/release.json": "
|
|
233
|
+
"spec/v2/release.json": "10c2702da9cc0a975b86d1a25e87512a4c7bd57a3e9a1699d326e0fb3cb07426"
|
|
234
234
|
},
|
|
235
|
-
"corpusCommit": "
|
|
235
|
+
"corpusCommit": "f90a4aaf3cb7b92d9c574572d7af3c85e4a23728"
|
|
236
236
|
}
|
package/src/cli.ts
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
type DiscoveryPayload,
|
|
55
55
|
PROFILE_FLOOR_SCENARIOS,
|
|
56
56
|
} from './lib/profiles.js';
|
|
57
|
-
import { setV2ProfileFloors } from './lib/requirement-registry.js';
|
|
57
|
+
import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
|
|
58
58
|
|
|
59
59
|
interface ParsedArgs {
|
|
60
60
|
readonly baseUrl: string | undefined;
|
|
@@ -377,35 +377,10 @@ function claimedProfilesForV2(doc: DiscoveryPayload, conformanceRoot: string): s
|
|
|
377
377
|
* fail a host for the corpus's own backlog.
|
|
378
378
|
*/
|
|
379
379
|
function v2ProfileFloors(conformanceRoot: string): Record<string, readonly string[]> {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
let known: Set<string>;
|
|
383
|
-
try {
|
|
384
|
-
known = new Set(Object.keys((JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8')) as { majors: Record<string, number[]> }).majors));
|
|
385
|
-
} catch {
|
|
386
|
-
known = new Set();
|
|
387
|
-
}
|
|
388
|
-
let registry: { profiles?: Array<{ id?: unknown; floorScenarios?: unknown }> };
|
|
389
|
-
try {
|
|
390
|
-
registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
391
|
-
} catch {
|
|
392
|
-
return {};
|
|
393
|
-
}
|
|
394
|
-
const out: Record<string, readonly string[]> = {};
|
|
395
|
-
for (const p of registry.profiles ?? []) {
|
|
396
|
-
if (typeof p.id !== 'string') continue;
|
|
397
|
-
const raw = Array.isArray(p.floorScenarios) ? (p.floorScenarios as unknown[]).map(String) : [];
|
|
398
|
-
const files: string[] = [];
|
|
399
|
-
for (const entry of raw) {
|
|
400
|
-
const name = entry.startsWith('planned:') ? `v2-${entry.slice('planned:'.length)}.test.ts` : entry.endsWith('.test.ts') ? entry : `${entry}.test.ts`;
|
|
401
|
-
if (known.size === 0 || known.has(name)) files.push(name);
|
|
402
|
-
}
|
|
403
|
-
out[p.id] = files;
|
|
404
|
-
}
|
|
405
|
-
return out;
|
|
380
|
+
// One derivation, shared with the ledger's floor-file set (requirement-registry.ts).
|
|
381
|
+
return v2ProfileFloorFiles(conformanceRoot);
|
|
406
382
|
}
|
|
407
383
|
|
|
408
|
-
/** A single scenario test file's terminal state, derived from the vitest JSON report. */
|
|
409
384
|
type ScenarioState = 'passed' | 'failed' | 'skipped';
|
|
410
385
|
|
|
411
386
|
/** The subset of vitest's JSON reporter output we read. */
|
package/src/lib/fixtures.ts
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* @see RFCS/0003-fixture-gating.md
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
+
import { softSkip } from './soft-skip.js';
|
|
44
45
|
import type { DiscoveryPayload } from './profiles.js';
|
|
45
46
|
|
|
46
47
|
let _advertisedFixtures: ReadonlySet<string> | null = null;
|
|
@@ -76,7 +77,29 @@ function loadOptedOutPredicate(): (id: string) => boolean {
|
|
|
76
77
|
* filtered out of the cache before storage so downstream lookups can
|
|
77
78
|
* stay a single sync set-membership test.
|
|
78
79
|
*/
|
|
80
|
+
let _discoveryUnreadable: string | null = null;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Suite init could not READ the discovery document. This is not "the host
|
|
84
|
+
* advertises no fixtures" and MUST NOT be recorded as such: with 311
|
|
85
|
+
* fixture-gated sites, an empty cache turns an entire lane into `inapplicable`
|
|
86
|
+
* rows that read exactly like a host that advertises nothing. Measured
|
|
87
|
+
* 2026-09-05: all four workers hit the 5 s init abort against a host that
|
|
88
|
+
* answers `/.well-known/openwop` in 120–200 ms (a Cloud Run cold start), and
|
|
89
|
+
* every fixture-gated scenario skipped as "not advertised". The cache stays
|
|
90
|
+
* null; every gate that consults it records `blocked` with this reason.
|
|
91
|
+
*/
|
|
92
|
+
export function setDiscoveryUnreadable(reason: string): void {
|
|
93
|
+
_advertisedFixtures = null;
|
|
94
|
+
_discoveryUnreadable = reason;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function discoveryUnreadableReason(): string | null {
|
|
98
|
+
return _discoveryUnreadable;
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
export function setAdvertisedFixtures(c: DiscoveryPayload | null | undefined): void {
|
|
102
|
+
_discoveryUnreadable = null;
|
|
80
103
|
if (c == null || !Array.isArray(c.fixtures)) {
|
|
81
104
|
_advertisedFixtures = new Set();
|
|
82
105
|
return;
|
|
@@ -103,6 +126,13 @@ export function setAdvertisedFixtures(c: DiscoveryPayload | null | undefined): v
|
|
|
103
126
|
* );
|
|
104
127
|
*/
|
|
105
128
|
export function isFixtureAdvertised(id: string): boolean {
|
|
129
|
+
if (_advertisedFixtures === null && _discoveryUnreadable !== null) {
|
|
130
|
+
// Evidence went unread, on the suite's side. `blocked` outranks the
|
|
131
|
+
// `inapplicable` the caller is about to record (soft-skip.ts RANK), so the
|
|
132
|
+
// file resolves to the honest word instead of the vacuous one.
|
|
133
|
+
softSkip('blocked', `suite init could not read /.well-known/openwop (${_discoveryUnreadable}) — fixture ${id} is UNKNOWN, not unadvertised`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
106
136
|
return _advertisedFixtures?.has(id) ?? false;
|
|
107
137
|
}
|
|
108
138
|
|
|
@@ -127,4 +157,5 @@ export function isFixtureCacheReady(): boolean {
|
|
|
127
157
|
/** Test-only: reset the module-level cache. */
|
|
128
158
|
export function __resetForTests(): void {
|
|
129
159
|
_advertisedFixtures = null;
|
|
160
|
+
_discoveryUnreadable = null;
|
|
130
161
|
}
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
28
|
|
|
26
29
|
/** `runs-lifecycle.test.ts` → `openwop.floor.runs-lifecycle`. */
|
|
27
30
|
export function requirementIdForScenario(scenarioFile: string): string {
|
|
@@ -124,3 +127,57 @@ export function allRequirements(): readonly string[] {
|
|
|
124
127
|
}
|
|
125
128
|
return [...ids].sort();
|
|
126
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The MAJOR-2 floors, derived from `spec/v2/profiles.json` (itself generated
|
|
133
|
+
* from `spec/v2/declaration.json`), keyed by profile id and resolved to
|
|
134
|
+
* scenario file names. A `planned:<stem>` entry names `v2-<stem>.test.ts`; an
|
|
135
|
+
* entry is kept only if `scenario-majors.json` knows the file.
|
|
136
|
+
*
|
|
137
|
+
* This function exists in ONE place on purpose. Until 2026-09-05 the CLI
|
|
138
|
+
* derived these floors privately for `--certify` while the ledger decided
|
|
139
|
+
* "is this file a floor?" from PROFILE_FLOOR_SCENARIOS — the v1 hand table,
|
|
140
|
+
* which knows no v2 file. So a v2 floor file was MINTED as
|
|
141
|
+
* `openwop.scenario.v2-…` and LOOKED UP at certify time as
|
|
142
|
+
* `openwop.floor.v2-…`: 101 executed-pass rows, `witnessCount: 0` on both
|
|
143
|
+
* claimed profiles, and `REJECTING — openwop-discovery-core: unclassified` on
|
|
144
|
+
* a tier-1 host's first production bundle. Two sources of the same fact, one
|
|
145
|
+
* of them stale, and the join between them silent.
|
|
146
|
+
*/
|
|
147
|
+
export function v2ProfileFloorFiles(conformanceRoot: string): Record<string, readonly string[]> {
|
|
148
|
+
const candidates: string[] = [];
|
|
149
|
+
try {
|
|
150
|
+
const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
|
|
151
|
+
candidates.push(resolvePath(dirname(req.resolve('@openwop/spec-artifacts/package.json')), 'spec', 'v2', 'profiles.json'));
|
|
152
|
+
} catch { /* not installed as a package; the repo-layout candidates below */ }
|
|
153
|
+
candidates.push(
|
|
154
|
+
resolvePath(conformanceRoot, 'spec', 'v2', 'profiles.json'),
|
|
155
|
+
resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'),
|
|
156
|
+
);
|
|
157
|
+
const registryPath = candidates.find((c) => existsSync(c));
|
|
158
|
+
if (registryPath === undefined) return {};
|
|
159
|
+
let known: Set<string>;
|
|
160
|
+
try {
|
|
161
|
+
known = new Set(Object.keys((JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8')) as { majors: Record<string, number[]> }).majors));
|
|
162
|
+
} catch {
|
|
163
|
+
known = new Set();
|
|
164
|
+
}
|
|
165
|
+
let registry: { profiles?: Array<{ id?: unknown; floorScenarios?: unknown }> };
|
|
166
|
+
try {
|
|
167
|
+
registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
168
|
+
} catch {
|
|
169
|
+
return {};
|
|
170
|
+
}
|
|
171
|
+
const out: Record<string, readonly string[]> = {};
|
|
172
|
+
for (const p of registry.profiles ?? []) {
|
|
173
|
+
if (typeof p.id !== 'string') continue;
|
|
174
|
+
const raw = Array.isArray(p.floorScenarios) ? (p.floorScenarios as unknown[]).map(String) : [];
|
|
175
|
+
const files: string[] = [];
|
|
176
|
+
for (const entry of raw) {
|
|
177
|
+
const name = entry.startsWith('planned:') ? `v2-${entry.slice('planned:'.length)}.test.ts` : entry.endsWith('.test.ts') ? entry : `${entry}.test.ts`;
|
|
178
|
+
if (known.size === 0 || known.has(name)) files.push(name);
|
|
179
|
+
}
|
|
180
|
+
out[p.id] = files;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
|
|
29
29
|
import { scenarioFileOfItId } from './requirement-ids.js';
|
|
30
30
|
import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
31
|
+
import { targetMajor } from './seams.js';
|
|
32
|
+
import { PKG_ROOT_PATH } from './paths.js';
|
|
33
|
+
import { v2ProfileFloorFiles } from './requirement-registry.js';
|
|
31
34
|
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
|
|
32
35
|
import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
|
|
33
36
|
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
@@ -40,6 +43,14 @@ export function floorScenarioFiles(): ReadonlySet<string> {
|
|
|
40
43
|
for (const f of floor.required) out.add(f);
|
|
41
44
|
for (const c of floor.conditional ?? []) for (const f of c.required) out.add(f);
|
|
42
45
|
}
|
|
46
|
+
// At target major 2 the floors come from the declaration, not the v1 hand
|
|
47
|
+
// table. The ledger and --certify MUST agree on this set, or a floor file is
|
|
48
|
+
// minted `openwop.scenario.*` and looked up as `openwop.floor.*` — which is
|
|
49
|
+
// exactly what refused a tier-1 host's first production bundle over 101
|
|
50
|
+
// executed-pass rows (see v2ProfileFloorFiles).
|
|
51
|
+
if (targetMajor() === 2) {
|
|
52
|
+
for (const files of Object.values(v2ProfileFloorFiles(PKG_ROOT_PATH))) for (const f of files) out.add(f);
|
|
53
|
+
}
|
|
43
54
|
return out;
|
|
44
55
|
}
|
|
45
56
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/versioning.md` §1.4 + `errors.md` — a malformed JSON body on a
|
|
3
|
+
* major-2 POST is answered by the HOST: `400`, the error envelope, and the
|
|
4
|
+
* `OpenWOP-Version` header (suite 2.0.0, target major 2; unaided; creates
|
|
5
|
+
* nothing).
|
|
6
|
+
*
|
|
7
|
+
* §1.4: "A response on any path MUST carry `OpenWOP-Version`". The response
|
|
8
|
+
* most likely to escape that rule is the one a framework produces before the
|
|
9
|
+
* host's own middleware runs. Measured on a tier-2 host's public origin on
|
|
10
|
+
* 2026-09-05: `POST /runs` with body `{` answered `400 text/html` with no
|
|
11
|
+
* header under BOTH majors — Express's JSON parser was mounted before
|
|
12
|
+
* negotiation, and its error left the middleware chain for the default HTML
|
|
13
|
+
* handler. Every well-formed request on that host carried the header; only
|
|
14
|
+
* the malformed one did not, which is exactly the request a scenario never
|
|
15
|
+
* sends by accident. The host suggested this probe: "`POST /runs` with `{` is
|
|
16
|
+
* cheaper than any run."
|
|
17
|
+
*
|
|
18
|
+
* The same probe distinguishes a host from the hosting layer in front of it: a
|
|
19
|
+
* fallback that answers `200 text/html` to everything fails here too.
|
|
20
|
+
*
|
|
21
|
+
* @see spec/v2/core/versioning.md §1.4
|
|
22
|
+
* @see spec/v2/core/errors.md
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { loadEnv } from '../lib/env.js';
|
|
27
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
28
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
29
|
+
import { req } from '../lib/requirement-ids.js';
|
|
30
|
+
|
|
31
|
+
const ID = 'openwop.requirement.0172.malformed-body-envelope';
|
|
32
|
+
const DOC = 'spec/v2/core/versioning.md §1.4';
|
|
33
|
+
|
|
34
|
+
interface Answer { readonly status: number; readonly version: string | null; readonly contentType: string; readonly body: unknown; readonly text: string }
|
|
35
|
+
|
|
36
|
+
async function postMalformed(path: string): Promise<Answer | null> {
|
|
37
|
+
const { baseUrl, apiKey } = loadEnv();
|
|
38
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json', 'OpenWOP-Version': '2.0' };
|
|
39
|
+
if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, { method: 'POST', headers, body: '{' });
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
let body: unknown = null;
|
|
44
|
+
try { body = JSON.parse(text); } catch { body = null; }
|
|
45
|
+
return { status: res.status, version: res.headers.get('openwop-version'), contentType: res.headers.get('content-type') ?? '', body, text };
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('v2 malformed-body-envelope (versioning.md §1.4; errors.md)', () => {
|
|
52
|
+
for (const path of ['/runs', '/webhooks']) {
|
|
53
|
+
it(`POST ${path} with a malformed JSON body is answered by the host: 400, the error envelope, OpenWOP-Version`, async () => {
|
|
54
|
+
try { if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable'); } catch { return softSkip('blocked', 'v2 discovery unreachable'); }
|
|
55
|
+
const a = await postMalformed(path);
|
|
56
|
+
if (a === null) return softSkip('blocked', `POST ${path} unreachable (fetch failed)`);
|
|
57
|
+
if (a.status === 404 && a.version !== null) return softSkip('inapplicable', `${path} is not mounted on this host (404 with the header — a host answer, not a fallback)`);
|
|
58
|
+
|
|
59
|
+
expect(
|
|
60
|
+
a.version,
|
|
61
|
+
req(ID, DOC, `POST ${path} with body '{': the response MUST carry OpenWOP-Version — a response without it did not come from the host's negotiation layer (a framework parser or a hosting fallback answered instead). Got status ${a.status}, content-type ${JSON.stringify(a.contentType)}`),
|
|
62
|
+
).not.toBeNull();
|
|
63
|
+
expect(
|
|
64
|
+
a.status,
|
|
65
|
+
req(ID, 'spec/v2/core/errors.md', `a malformed JSON body MUST be refused with 400 validation_error, not accepted and not passed to a default handler (got ${a.status})`),
|
|
66
|
+
).toBe(400);
|
|
67
|
+
expect(
|
|
68
|
+
a.body !== null && typeof a.body === 'object' && !/text\/html/i.test(a.contentType),
|
|
69
|
+
req(ID, 'spec/v2/core/errors.md', `the 400 MUST be the JSON error envelope, not a framework's HTML error page (content-type ${JSON.stringify(a.contentType)}, body starts ${JSON.stringify(a.text.slice(0, 40))})`),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
const r = v2Validator('error-envelope')(a.body);
|
|
72
|
+
expect(r.ok, req(ID, 'spec/v2/core/errors.md', `the body MUST validate against error-envelope.schema.json (${r.errors})`)).toBe(true);
|
|
73
|
+
expect(
|
|
74
|
+
(a.body as { error?: unknown }).error,
|
|
75
|
+
req(ID, 'spec/v2/core/errors.md', 'the envelope\'s `error` MUST be the registered code for a malformed request body, validation_error'),
|
|
76
|
+
).toBe('validation_error');
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
package/src/setup.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* `it` here; vitest treats setupFiles differently from scenario files.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { setAdvertisedFixtures } from './lib/fixtures.js';
|
|
28
|
+
import { setAdvertisedFixtures, setDiscoveryUnreadable } from './lib/fixtures.js';
|
|
29
29
|
import { setMultiAgentCapabilities } from './lib/multi-agent-capabilities.js';
|
|
30
30
|
import { OtelCollector, setCollector } from './lib/otel-collector.js';
|
|
31
31
|
import { McpFakeServer, setMcpFakeServer } from './lib/mcp-fake-server.js';
|
|
@@ -43,13 +43,15 @@ import type { DiscoveryPayload } from './lib/profiles.js';
|
|
|
43
43
|
import { targetMajor } from './lib/seams.js';
|
|
44
44
|
import { softSkip } from './lib/soft-skip.js';
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// 20 s, not 5: a Cloud Run cold start routinely exceeds 5 s, and a discovery
|
|
47
|
+
// fetch that aborted at init used to turn every fixture-gated scenario into a
|
|
48
|
+
// vacuous `inapplicable`. Measured 2026-09-05 on a host answering in 200 ms.
|
|
49
|
+
const SUITE_INIT_TIMEOUT_MS = 20_000;
|
|
50
|
+
const SUITE_INIT_ATTEMPTS = 2;
|
|
47
51
|
|
|
48
52
|
async function loadHostFixtures(): Promise<void> {
|
|
49
53
|
const baseUrl = process.env.OPENWOP_BASE_URL?.trim();
|
|
50
54
|
if (!baseUrl) {
|
|
51
|
-
// Offline / fixture-stub-only run. No host to ask; treat as "host
|
|
52
|
-
// advertises no fixtures" so all fixture-dependent scenarios skip.
|
|
53
55
|
setAdvertisedFixtures(null);
|
|
54
56
|
setMultiAgentCapabilities(null);
|
|
55
57
|
return;
|
|
@@ -57,39 +59,36 @@ async function loadHostFixtures(): Promise<void> {
|
|
|
57
59
|
|
|
58
60
|
const normalizedBase = baseUrl.replace(/\/$/, '');
|
|
59
61
|
const url = `${normalizedBase}/.well-known/openwop`;
|
|
62
|
+
// The representation the header selects is the one whose fixtures[] this
|
|
63
|
+
// lane is held to (versioning.md §5): read it under the lane's major.
|
|
64
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
65
|
+
if (targetMajor() === 2) headers['OpenWOP-Version'] = '2.0';
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
);
|
|
75
|
-
setAdvertisedFixtures(null);
|
|
76
|
-
setMultiAgentCapabilities(null);
|
|
67
|
+
let lastFailure = 'unknown';
|
|
68
|
+
for (let attempt = 1; attempt <= SUITE_INIT_ATTEMPTS; attempt += 1) {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => controller.abort(), SUITE_INIT_TIMEOUT_MS);
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(url, { method: 'GET', headers, signal: controller.signal });
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
lastFailure = `HTTP ${res.status} on attempt ${attempt}`;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const body = (await res.json()) as DiscoveryPayload;
|
|
78
|
+
setAdvertisedFixtures(body);
|
|
79
|
+
setMultiAgentCapabilities(body);
|
|
77
80
|
return;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
lastFailure = `${(err as Error).message ?? 'unknown'} on attempt ${attempt}`;
|
|
83
|
+
} finally {
|
|
84
|
+
clearTimeout(timer);
|
|
78
85
|
}
|
|
79
|
-
const body = (await res.json()) as DiscoveryPayload;
|
|
80
|
-
setAdvertisedFixtures(body);
|
|
81
|
-
setMultiAgentCapabilities(body);
|
|
82
|
-
} catch (err) {
|
|
83
|
-
// eslint-disable-next-line no-console
|
|
84
|
-
console.warn(
|
|
85
|
-
`[openwop-conformance setup] discovery fetch failed (${(err as Error).message ?? 'unknown'}); ` +
|
|
86
|
-
`treating host as advertising no fixtures. Fixture-dependent scenarios will skip.`,
|
|
87
|
-
);
|
|
88
|
-
setAdvertisedFixtures(null);
|
|
89
|
-
setMultiAgentCapabilities(null);
|
|
90
|
-
} finally {
|
|
91
|
-
clearTimeout(timer);
|
|
92
86
|
}
|
|
87
|
+
// Not "the host advertises no fixtures". The document went UNREAD, and every
|
|
88
|
+
// fixture gate will say so as `blocked` rather than `inapplicable`.
|
|
89
|
+
console.warn(`[openwop-conformance setup] discovery unreadable after ${SUITE_INIT_ATTEMPTS} attempt(s) (${lastFailure}); fixture-gated scenarios will record blocked, not inapplicable.`);
|
|
90
|
+
setDiscoveryUnreadable(lastFailure);
|
|
91
|
+
setMultiAgentCapabilities(null);
|
|
93
92
|
}
|
|
94
93
|
|
|
95
94
|
/**
|