@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.2
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 +209 -14
- package/dist/lib/certification-bundle-v3.js +142 -0
- package/dist/lib/requirement-ids.js +23 -0
- package/dist/lib/requirement-ledger.js +1 -0
- package/dist/lib/scenario-disposition.js +5 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2744 -4
- package/scenario-majors.json +155 -2
- package/schemas/CORPUS-STAMP.json +17 -17
- package/src/cli.ts +190 -19
- package/src/lib/certification-bundle-v3.ts +175 -0
- package/src/lib/driver.ts +9 -1
- package/src/lib/era2-seed.ts +197 -0
- package/src/lib/requirement-ids.ts +21 -0
- package/src/lib/requirement-ledger.ts +7 -1
- package/src/lib/scenario-disposition.ts +5 -1
- package/src/lib/seams.ts +45 -0
- package/src/lib/v2.ts +62 -0
- package/src/scenarios/v2-approver-enforced.test.ts +97 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
- package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
- package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
- package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
- package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
- package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
- package/src/scenarios/v2-configurable-closed.test.ts +67 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
- package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
- package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
- package/src/scenarios/v2-era-key.test.ts +86 -0
- package/src/scenarios/v2-error-registry.test.ts +89 -0
- package/src/scenarios/v2-event-type-closed.test.ts +97 -0
- package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
- package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
- package/src/scenarios/v2-header-scheme.test.ts +67 -0
- package/src/scenarios/v2-id-grammar.test.ts +77 -0
- package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
- package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
- package/src/scenarios/v2-min-client-version.test.ts +48 -0
- package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
- package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
- package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
- package/src/scenarios/v2-pack-isolation.test.ts +147 -0
- package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
- package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
- package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
- package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
- package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
- package/src/scenarios/v2-provider-conflict.test.ts +115 -0
- package/src/scenarios/v2-refresh-sla.test.ts +78 -0
- package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
- package/src/scenarios/v2-revocation-honored.test.ts +66 -0
- package/src/scenarios/v2-subject-link-record.test.ts +102 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
- package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
- package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
- package/src/setup.ts +40 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0173 §B — `approver-enforced` (suite 2.0.0, target major 2; gated on `interrupt` + `refKinds`).
|
|
3
|
+
*
|
|
4
|
+
* Enforcement is an obligation of the fields, not a discovery flag: a host that
|
|
5
|
+
* advertises `interrupt` MUST refuse a resolver not in `approversList`
|
|
6
|
+
* everywhere, and MUST enforce `approverGroupRefs` / `approverRoleRefs` where
|
|
7
|
+
* `refKinds` includes `group` / `role` (`spec/v2/core/interrupt.md` §Approver
|
|
8
|
+
* enforcement; security-defaults.md §Approver enforcement; RFC 0173 §B row C6.4).
|
|
9
|
+
*
|
|
10
|
+
* Gate: the `interrupt` family with a `refKinds[]` facet
|
|
11
|
+
* (`spec/v2/facets/interrupt.schema.json`). Absent facet ⇒ `inapplicable`.
|
|
12
|
+
*
|
|
13
|
+
* What the suite can drive: it holds ONE bearer. A fixture whose approval gate
|
|
14
|
+
* names a synthetic principal the suite is not (`conformance-approval-approvers`,
|
|
15
|
+
* approversList: ["urn:conformance:listed-approver"]) makes the suite's own
|
|
16
|
+
* bearer the non-listed resolver by construction. No such fixture ships in
|
|
17
|
+
* `conformance/fixtures/` (`conformance-approval` has no approversList), so a
|
|
18
|
+
* host that does not advertise it records `blocked` naming it.
|
|
19
|
+
*
|
|
20
|
+
* @see spec/v2/core/interrupt.md §Approver enforcement
|
|
21
|
+
* @see spec/v2/core/security-defaults.md §Approver enforcement
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest';
|
|
25
|
+
import { driver } from '../lib/driver.js';
|
|
26
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
27
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
28
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
29
|
+
import { req } from '../lib/requirement-ids.js';
|
|
30
|
+
|
|
31
|
+
/** An approval fixture whose `approversList` names a principal the suite's bearer is not. */
|
|
32
|
+
const FIXTURE = 'conformance-approval-approvers';
|
|
33
|
+
const NODE_ID = 'gate';
|
|
34
|
+
const REF_KINDS = ['principal', 'group', 'role'];
|
|
35
|
+
|
|
36
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
37
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function waitStatus(runId: string, wanted: ReadonlySet<string>, timeoutMs: number): Promise<string | null> {
|
|
41
|
+
const deadline = Date.now() + timeoutMs;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
44
|
+
const status = res.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
45
|
+
if (status !== null && wanted.has(status)) return status;
|
|
46
|
+
if (Date.now() > deadline) return status;
|
|
47
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('RFC 0173 §B — approver-enforced (gated on interrupt + refKinds)', () => {
|
|
52
|
+
it('a resolver outside approversList is refused at resolve time', async () => {
|
|
53
|
+
const doc = await discovery();
|
|
54
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
55
|
+
const interrupt = await gateFamily('interrupt');
|
|
56
|
+
if (!interrupt) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
57
|
+
const refKinds = interrupt['refKinds'];
|
|
58
|
+
if (!Array.isArray(refKinds)) return softSkip('inapplicable', 'interrupt.refKinds facet absent — the host advertises no approver ref kinds (interrupt.md §Approver enforcement binds approversList everywhere, but the facet is the gate this scenario keys on)');
|
|
59
|
+
for (const k of refKinds) {
|
|
60
|
+
expect(
|
|
61
|
+
REF_KINDS,
|
|
62
|
+
req('openwop.requirement.0173.approver-enforced', 'facets/interrupt.schema.json refKinds', `refKinds[] MUST be ⊆ principal | group | role (got ${String(k)})`),
|
|
63
|
+
).toContain(k);
|
|
64
|
+
}
|
|
65
|
+
const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
|
|
66
|
+
if (!fixtures.includes(FIXTURE)) {
|
|
67
|
+
return softSkip('blocked', `no interrupt-raising fixture with an approversList the suite's bearer is not on — advertise \`${FIXTURE}\` (a core.approvalGate whose approversList is ["urn:conformance:listed-approver"]) so a non-listed resolve can be driven`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
71
|
+
expect(create.status, req('openwop.requirement.0173.approver-enforced', 'runs.md §Create', `POST /runs MUST answer 201 for the advertised ${FIXTURE} fixture`)).toBe(201);
|
|
72
|
+
const runId = (create.json as { runId: string }).runId;
|
|
73
|
+
const status = await waitStatus(runId, new Set(['waiting-approval', 'completed', 'failed', 'cancelled']), 10_000);
|
|
74
|
+
expect(
|
|
75
|
+
status,
|
|
76
|
+
req('openwop.requirement.0173.approver-enforced', 'interrupt.md §Approver enforcement', 'the fixture MUST suspend on its approval gate (status waiting-approval) before a resolve can be attempted'),
|
|
77
|
+
).toBe('waiting-approval');
|
|
78
|
+
|
|
79
|
+
// The suite's bearer is not `urn:conformance:listed-approver`.
|
|
80
|
+
const resolve = await driver.post(`/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`, { resumeValue: { action: 'accept' } });
|
|
81
|
+
expect(
|
|
82
|
+
resolve.status,
|
|
83
|
+
req('openwop.requirement.0173.approver-enforced', 'interrupt.md §Approver enforcement', 'a host advertising `interrupt` MUST refuse a resolver not in approversList with 403 (RFC 0173 §B — enforcement, not advice)'),
|
|
84
|
+
).toBe(403);
|
|
85
|
+
expect(
|
|
86
|
+
['forbidden', 'run_forbidden'],
|
|
87
|
+
req('openwop.requirement.0173.approver-enforced', 'errors.json', 'the refusal MUST carry a registered 403 code (forbidden | run_forbidden) in the canonical envelope'),
|
|
88
|
+
).toContain(readErrorCode(resolve.json));
|
|
89
|
+
// The gate is still pending: a refused resolve MUST NOT advance it.
|
|
90
|
+
const after = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
91
|
+
expect(
|
|
92
|
+
(after.json as { status?: unknown } | null)?.status,
|
|
93
|
+
req('openwop.requirement.0173.approver-enforced', 'interrupt.md §Approver enforcement', 'a refused resolve MUST NOT resume the run — the interrupt stays pending'),
|
|
94
|
+
).toBe('waiting-approval');
|
|
95
|
+
await driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `assurance-downgrade-audited` (suite 2.0.0; RFC 0170 §B.4;
|
|
3
|
+
* `spec/v2/core/identity.md` §2.3 "Minimum assurance"; RFC 0154 R3).
|
|
4
|
+
*
|
|
5
|
+
* Witness class: seam-gated (`openwop-conformance-seams-v2`). A request below
|
|
6
|
+
* the lane's `minimumAssurance` MUST be refused with
|
|
7
|
+
* `401 sender_constraint_missing`, and an audit fact MUST record the assurance
|
|
8
|
+
* actually used; a bearer fallback never inherits a sender-constrained label.
|
|
9
|
+
* Driven through the §20 workload-identity seam
|
|
10
|
+
* (`POST /conformance/seams/sample/test/workload-identity/resolve`, the
|
|
11
|
+
* v1-shaped path through `seamPath()`): an identity presented WITHOUT
|
|
12
|
+
* `keyBinding` on a `workload` lane whose floor is above `bearer` MUST be
|
|
13
|
+
* refused; an identity WITH a key binding resolves, and the seam's response is
|
|
14
|
+
* expected to surface `assurance` (the audited fact) — §20 does not yet declare
|
|
15
|
+
* that field, so its absence records `blocked` rather than a pass.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest';
|
|
19
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
20
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
21
|
+
import { seamPath, seamsProfileAdvertised } from '../lib/seams.js';
|
|
22
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
23
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
24
|
+
import { req } from '../lib/requirement-ids.js';
|
|
25
|
+
|
|
26
|
+
const DOC = 'spec/v2/core/identity.md §2.3';
|
|
27
|
+
const SEAM = seamPath('/v1/host/sample/test/workload-identity/resolve');
|
|
28
|
+
const IDENTITY = { scheme: 'spiffe', subject: 'spiffe://example/dispatcher', issuer: 'spiffe://example', audience: 'openwop-host' };
|
|
29
|
+
const ASSURANCE = new Set(['bearer', 'sender-constrained', 'key-bound']);
|
|
30
|
+
|
|
31
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
32
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
35
|
+
try { return await fn(); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The advertised workload lane when its floor is above bearer, else a reason. */
|
|
39
|
+
async function gate(): Promise<{ floor: string } | { kind: 'blocked' | 'inapplicable'; reason: string }> {
|
|
40
|
+
const doc = await discovery();
|
|
41
|
+
if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
42
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'blocked', reason: 'seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — the assurance floor is seam-gated' };
|
|
43
|
+
const auth = await familyAdvertised('auth');
|
|
44
|
+
const lanes = Array.isArray(auth?.['lanes']) ? (auth['lanes'] as Array<Record<string, unknown>>) : [];
|
|
45
|
+
const workload = lanes.find((l) => l['lane'] === 'workload');
|
|
46
|
+
if (workload === undefined) return { kind: 'inapplicable', reason: 'the host does not advertise a `workload` lane' };
|
|
47
|
+
const floor = String(workload['minimumAssurance']);
|
|
48
|
+
if (floor === 'bearer') return { kind: 'inapplicable', reason: 'the workload lane advertises minimumAssurance: bearer — no request can be below the floor' };
|
|
49
|
+
return { floor };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('v2 assurance-downgrade-audited (RFC 0170 §B.4 — seam-gated)', () => {
|
|
53
|
+
it('an identity below the lane floor is refused with sender_constraint_missing', async () => {
|
|
54
|
+
const g = await gate();
|
|
55
|
+
if ('kind' in g) return softSkip(g.kind, g.reason);
|
|
56
|
+
const res = await http(() => driver.post(SEAM, { identity: IDENTITY, expectedAudience: 'openwop-host' }));
|
|
57
|
+
if (res === null) return softSkip('blocked', `${SEAM} unreachable (fetch failed)`);
|
|
58
|
+
if (res.status === 404 || res.status === 403) return seamAbsent(`${SEAM} not mounted (${res.status}) — host-sample-test-seams.md §20`);
|
|
59
|
+
expect(res.status, req('openwop.requirement.0170.assurance-downgrade-audited.refused', DOC, `the workload lane advertises minimumAssurance: ${g.floor}; an identity presented without a key binding is below the floor and MUST be refused with 401`)).toBe(401);
|
|
60
|
+
expect(readErrorCode(res.json), req('openwop.requirement.0170.assurance-downgrade-audited.refused', DOC, 'the refusal code MUST be sender_constraint_missing')).toBe('sender_constraint_missing');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('a resolved identity records the assurance actually used, never a downgraded bearer under a sender-constrained label', async () => {
|
|
64
|
+
const g = await gate();
|
|
65
|
+
if ('kind' in g) return softSkip(g.kind, g.reason);
|
|
66
|
+
const res = await http(() => driver.post(SEAM, { identity: { ...IDENTITY, keyBinding: { method: 'mtls', thumbprintRef: 'openwop-conformance-thumbprint' } }, expectedAudience: 'openwop-host' }));
|
|
67
|
+
if (res === null) return softSkip('blocked', `${SEAM} unreachable (fetch failed)`);
|
|
68
|
+
if (res.status === 404 || res.status === 403) return seamAbsent(`${SEAM} not mounted (${res.status}) — host-sample-test-seams.md §20`);
|
|
69
|
+
if (res.status !== 200) return softSkip('blocked', `the seam refused the key-bound identity (${res.status} ${readErrorCode(res.json) ?? ''}) — the audit fact of a resolved request cannot be read`);
|
|
70
|
+
const assurance = (res.json as { assurance?: unknown } | undefined)?.assurance;
|
|
71
|
+
if (assurance === undefined) return seamAbsent('the §20 seam response carries no `assurance` field — RFC 0170 §B.4 "an audit fact MUST record the assurance actually used" is unobservable until the seam surfaces it (RFC 0154 R3)');
|
|
72
|
+
expect(ASSURANCE.has(String(assurance)), req('openwop.requirement.0170.assurance-downgrade-audited.audited', DOC, `the audited assurance MUST be one of bearer | sender-constrained | key-bound (got ${String(assurance)})`)).toBe(true);
|
|
73
|
+
expect(assurance !== 'bearer', req('openwop.requirement.0170.assurance-downgrade-audited.audited', DOC, `a key-bound presentation resolved above the ${g.floor} floor MUST NOT be recorded as bearer, and a bearer fallback MUST NOT inherit a sender-constrained label (invariant sender-constraint-no-bearer-downgrade)`)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-bundle-v3-signed — RFC 0168 §E.1/§E.2; `spec/v2/core/conformance.md`
|
|
3
|
+
* §"Bundle v3".
|
|
4
|
+
*
|
|
5
|
+
* Suite 2.0.0. A certification bundle validates against
|
|
6
|
+
* `schemas/v2/certification-bundle.schema.json` (closed root, `bundleVersion: "3"`),
|
|
7
|
+
* carries `witnessSha256` over the reporter record and an Ed25519 attestation
|
|
8
|
+
* over `{ witnessSha256, host.build, suite.version, discovery.sha256 }`; an
|
|
9
|
+
* `evidenceTier: independent` claim MUST carry a verifier signature under a key
|
|
10
|
+
* distinct from the host's, and the verifier MUST refuse, not warn. Unaided and
|
|
11
|
+
* fixture-based, with `lib/certification-bundle-v3.ts` as the signer/verifier:
|
|
12
|
+
*
|
|
13
|
+
* 1. §E.1 — a signed fixture validates against the schema; the same fixture
|
|
14
|
+
* with one extra root key is rejected (closed root).
|
|
15
|
+
* 2. §E.2 — the signed fixture verifies under the host key; flipping
|
|
16
|
+
* `witnessSha256` is refused with `witness-digest` (and the signature no
|
|
17
|
+
* longer verifies, since the digest is inside the attestation payload).
|
|
18
|
+
* 3. §E.2 — an `independent` claim whose verifier signature uses the host's
|
|
19
|
+
* key id is refused with `independent-self-signed`; a distinct verifier
|
|
20
|
+
* key verifies.
|
|
21
|
+
*
|
|
22
|
+
* @see RFCS/0168-v2-evidence-and-conformance.md §E.1, §E.2
|
|
23
|
+
* @see spec/v2/core/conformance.md §"Bundle v3"
|
|
24
|
+
* @see conformance/src/lib/certification-bundle-v3.ts
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
29
|
+
import { req } from '../lib/requirement-ids.js';
|
|
30
|
+
import { v2Validator } from '../lib/v2.js';
|
|
31
|
+
import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest, type BundleV3, type BundleV3Requirement } from '../lib/certification-bundle-v3.js';
|
|
32
|
+
|
|
33
|
+
const SECTION = 'conformance.md §"Bundle v3" (RFC 0168 §E)';
|
|
34
|
+
const HOST_KEY_ID = 'fixture-host-key';
|
|
35
|
+
|
|
36
|
+
function keyPem(): string {
|
|
37
|
+
return generateKeyPairSync('ed25519').privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fixture(hostPem: string): BundleV3 {
|
|
41
|
+
const rows: BundleV3Requirement[] = [
|
|
42
|
+
{ id: 'openwop.requirement.0168.bundle-v3-signed.fixture-a', scenario: 'v2-bundle-v3-signed.test.ts', result: 'executed-pass', assertions: 2 },
|
|
43
|
+
{ id: 'openwop.requirement.0168.bundle-v3-signed.fixture-b', scenario: 'v2-bundle-v3-signed.test.ts', result: 'executed-pass', assertions: 1 },
|
|
44
|
+
];
|
|
45
|
+
const unsigned: Omit<BundleV3, 'signature'> = {
|
|
46
|
+
bundleVersion: '3',
|
|
47
|
+
generatedAt: new Date().toISOString(),
|
|
48
|
+
suite: { name: '@openwop/openwop-conformance', version: '2.0.0', targetMajor: 2, specArtifactsVersion: '2.0.0' },
|
|
49
|
+
host: { name: 'fixture-host', version: '0.0.0', build: { kind: 'commit', id: 'fixture' }, signingKeyId: HOST_KEY_ID },
|
|
50
|
+
discovery: { url: 'https://host.invalid/.well-known/openwop', sha256: 'b'.repeat(64), protocolVersions: ['2.0'], preferredVersion: '2.0' },
|
|
51
|
+
claimedProfiles: [{ id: 'openwop-core-v2', evidenceTier: 'self', witnessCount: 2, certified: true }],
|
|
52
|
+
results: { totals: { executedPass: 2, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 }, requirements: rows },
|
|
53
|
+
witnessSha256: witnessDigest(rows),
|
|
54
|
+
assertionCount: 3,
|
|
55
|
+
};
|
|
56
|
+
return { ...unsigned, signature: signBundleV3(unsigned, hostPem, HOST_KEY_ID) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('v2-bundle-v3-signed (RFC 0168 §E)', () => {
|
|
60
|
+
it('a signed v3 bundle validates against the closed-root schema; an extra root key is rejected', () => {
|
|
61
|
+
const validate = v2Validator('certification-bundle');
|
|
62
|
+
const bundle = fixture(keyPem());
|
|
63
|
+
const ok = validate(bundle);
|
|
64
|
+
expect(ok.ok, req('openwop.requirement.0168.bundle-v3-signed.schema-closed-root', SECTION, `a signed v3 bundle with the required fields MUST validate (${ok.errors})`)).toBe(true);
|
|
65
|
+
const open = validate({ ...bundle, vendorNote: 'not a v3 field' });
|
|
66
|
+
expect(open.ok, req('openwop.requirement.0168.bundle-v3-signed.schema-closed-root', SECTION, 'the v3 root is closed — an unknown root key MUST be rejected (RFC 0168 §E.1)')).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('the attestation verifies under the host key, and a flipped witnessSha256 is refused with witness-digest', () => {
|
|
70
|
+
const hostPem = keyPem();
|
|
71
|
+
const bundle = fixture(hostPem);
|
|
72
|
+
const hostPublicKeyPem = publicKeyFromPrivate(hostPem);
|
|
73
|
+
const clean = verifyBundleV3(bundle, { hostPublicKeyPem });
|
|
74
|
+
expect(clean.rejections, req('openwop.requirement.0168.bundle-v3-signed.witness-digest', SECTION, `an untampered signed bundle MUST verify with no rejections (${clean.rejections.map((r) => r.kind).join(',')})`)).toEqual([]);
|
|
75
|
+
expect(clean.signatureVerified, req('openwop.requirement.0168.bundle-v3-signed.witness-digest', SECTION, 'the Ed25519 attestation MUST verify under the host key')).toBe(true);
|
|
76
|
+
const flipped: BundleV3 = { ...bundle, witnessSha256: bundle.witnessSha256.startsWith('0') ? `1${bundle.witnessSha256.slice(1)}` : `0${bundle.witnessSha256.slice(1)}` };
|
|
77
|
+
const verdict = verifyBundleV3(flipped, { hostPublicKeyPem });
|
|
78
|
+
expect(verdict.rejections.map((r) => r.kind), req('openwop.requirement.0168.bundle-v3-signed.witness-digest', SECTION, 'a witnessSha256 that is not the digest of the rows MUST be refused with witness-digest (RFC 0148 §C)')).toContain('witness-digest');
|
|
79
|
+
expect(verdict.signatureVerified, req('openwop.requirement.0168.bundle-v3-signed.witness-digest', SECTION, 'the attestation covers witnessSha256, so a flipped digest MUST NOT verify under the host key')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('an independent claim self-signed under the host key is refused; a distinct verifier key verifies', () => {
|
|
83
|
+
const hostPem = keyPem();
|
|
84
|
+
const verifierPem = keyPem();
|
|
85
|
+
const base = fixture(hostPem);
|
|
86
|
+
const independent: BundleV3 = { ...base, claimedProfiles: [{ id: 'openwop-core-v2', evidenceTier: 'independent', witnessCount: 2, certified: true }] };
|
|
87
|
+
const selfSigned: BundleV3 = { ...independent, verifierSignature: verifierSign(independent, hostPem, HOST_KEY_ID) };
|
|
88
|
+
const refused = verifyBundleV3(selfSigned, { hostPublicKeyPem: publicKeyFromPrivate(hostPem), verifierPublicKeyPem: publicKeyFromPrivate(hostPem) });
|
|
89
|
+
expect(refused.rejections.map((r) => r.kind), req('openwop.requirement.0168.bundle-v3-signed.independent-self-signed', SECTION, 'an independent claim whose verifier key id equals the host key id MUST be refused with independent-self-signed (RFC 0148 R5)')).toContain('independent-self-signed');
|
|
90
|
+
expect(refused.certifiedProfiles, req('openwop.requirement.0168.bundle-v3-signed.independent-self-signed', SECTION, 'a refused independent claim MUST NOT certify')).not.toContain('openwop-core-v2');
|
|
91
|
+
const distinct: BundleV3 = { ...independent, verifierSignature: verifierSign(independent, verifierPem, 'fixture-verifier-key') };
|
|
92
|
+
const accepted = verifyBundleV3(distinct, { hostPublicKeyPem: publicKeyFromPrivate(hostPem), verifierPublicKeyPem: publicKeyFromPrivate(verifierPem) });
|
|
93
|
+
expect(accepted.rejections, req('openwop.requirement.0168.bundle-v3-signed.independent-self-signed', SECTION, `a distinct verifier key MUST verify with no rejections (${accepted.rejections.map((r) => r.kind).join(',')})`)).toEqual([]);
|
|
94
|
+
expect(accepted.verifierSignatureVerified, req('openwop.requirement.0168.bundle-v3-signed.independent-self-signed', SECTION, 'the verifier attestation MUST verify under the verifier key')).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `capabilities-root-closed` (suite 2.0.0; RFC 0169 §A.4, §C.1;
|
|
3
|
+
* `spec/v2/core/capabilities.md` §3 "The closed root").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. The v2 representation of
|
|
6
|
+
* `/.well-known/openwop` MUST validate against `schemas/v2/capabilities.schema.json`,
|
|
7
|
+
* whose root is `additionalProperties: false`. A copy of the host's document with
|
|
8
|
+
* an unknown root key, a dotted mirror key, the v1 `capabilities` wrapper, or a
|
|
9
|
+
* root `profiles[]` injected MUST fail validation — the closure is a property of
|
|
10
|
+
* the schema artifact, so those legs run against the host's document when it is
|
|
11
|
+
* reachable and against the minimal valid root otherwise.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
16
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
17
|
+
import { req } from '../lib/requirement-ids.js';
|
|
18
|
+
|
|
19
|
+
const DOC = 'spec/v2/core/capabilities.md §3';
|
|
20
|
+
const MINIMAL_ROOT: Record<string, unknown> = { protocolVersions: ['2.0'], preferredVersion: '2.0' };
|
|
21
|
+
|
|
22
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
23
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The host's document when reachable, else the minimal valid v2 root. */
|
|
27
|
+
async function base(): Promise<Record<string, unknown>> {
|
|
28
|
+
return (await discovery()) ?? MINIMAL_ROOT;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('v2 capabilities-root-closed (RFC 0169 §A.4, §C.1)', () => {
|
|
32
|
+
it('the host document validates against capabilities.schema.json', async () => {
|
|
33
|
+
const doc = await discovery();
|
|
34
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
35
|
+
const validate = v2Validator('capabilities');
|
|
36
|
+
const r = validate(doc);
|
|
37
|
+
expect(r.ok, req('openwop.requirement.0169.capabilities-root-closed.valid', DOC, `the v2 discovery document MUST validate against schemas/v2/capabilities.schema.json (${r.errors})`)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('an unknown root key fails validation', async () => {
|
|
41
|
+
const validate = v2Validator('capabilities');
|
|
42
|
+
const injected = { ...(await base()), openwopConformanceUnknownRootKey: { status: 'stable', since: '2.0', witness: 'claims-check' } };
|
|
43
|
+
expect(validate(injected).ok, req('openwop.requirement.0169.capabilities-root-closed.unknown-root-key', DOC, 'any root key that is not a metadata key, a declared family, or `extensions` MUST fail validation')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('a dotted mirror key fails validation', async () => {
|
|
47
|
+
const validate = v2Validator('capabilities');
|
|
48
|
+
const injected = { ...(await base()), 'host.replay': { status: 'stable', since: '2.0', witness: 'claims-check' } };
|
|
49
|
+
expect(validate(injected).ok, req('openwop.requirement.0169.capabilities-root-closed.dotted-key', DOC, 'a dotted key (the v1 `host.<family>` mirror) MUST fail validation at the closed root')).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('the v1 `capabilities` wrapper fails validation', async () => {
|
|
53
|
+
const validate = v2Validator('capabilities');
|
|
54
|
+
const injected = { ...(await base()), capabilities: {} };
|
|
55
|
+
expect(validate(injected).ok, req('openwop.requirement.0169.capabilities-root-closed.wrapper', DOC, 'the v1 `capabilities` wrapper MUST fail validation at the closed root (migration row C2.1)')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('a root `profiles[]` fails validation', async () => {
|
|
59
|
+
const validate = v2Validator('capabilities');
|
|
60
|
+
const injected = { ...(await base()), profiles: ['openwop-discovery-core'] };
|
|
61
|
+
expect(validate(injected).ok, req('openwop.requirement.0169.capabilities-root-closed.profiles', 'spec/v2/core/capabilities.md §7', 'no `profiles[]` exists at the v2 root; a host that emits one MUST fail schema validation (row C2.10)')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `capability-record-shape` (suite 2.0.0; RFC 0169 §A.1–§A.3;
|
|
3
|
+
* `spec/v2/core/capabilities.md` §2 "The capability record").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. Every family record at the v2 root
|
|
6
|
+
* carries `status`, `since`, `witness`; `until` is present iff the record is
|
|
7
|
+
* not `stable`; an `until` already in the past makes the document
|
|
8
|
+
* non-conformant. Which root keys are family records is read from the
|
|
9
|
+
* generated `schemas/v2/capabilities.schema.json` (a property whose schema
|
|
10
|
+
* requires `status`, `since`, `witness`), so metadata keys (§3.1) are never
|
|
11
|
+
* mistaken for records.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
18
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
19
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
20
|
+
import { req } from '../lib/requirement-ids.js';
|
|
21
|
+
|
|
22
|
+
const DOC = 'spec/v2/core/capabilities.md §2';
|
|
23
|
+
const STATUS = new Set(['stable', 'experimental', 'deprecated']);
|
|
24
|
+
const WITNESS = new Set(['witnessable-unaided', 'witnessable-gated', 'seam-gated', 'claims-check', 'negative-existence']);
|
|
25
|
+
const VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
|
26
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
27
|
+
|
|
28
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
29
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Root keys the generated schema declares as family records. */
|
|
33
|
+
function familyKeys(): Set<string> {
|
|
34
|
+
const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'capabilities.schema.json'), 'utf8')) as { properties?: Record<string, { required?: string[] }> };
|
|
35
|
+
const out = new Set<string>();
|
|
36
|
+
for (const [key, prop] of Object.entries(schema.properties ?? {})) {
|
|
37
|
+
const required = prop.required ?? [];
|
|
38
|
+
if (['status', 'since', 'witness'].every((r) => required.includes(r))) out.add(key);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The records the host actually advertises, keyed by family. */
|
|
44
|
+
function records(doc: Record<string, unknown>): Array<[string, Record<string, unknown>]> {
|
|
45
|
+
const keys = familyKeys();
|
|
46
|
+
return Object.entries(doc)
|
|
47
|
+
.filter(([k, v]) => keys.has(k) && v !== null && typeof v === 'object' && !Array.isArray(v))
|
|
48
|
+
.map(([k, v]) => [k, v as Record<string, unknown>]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `until` in the past: a date form is compared with today's UTC date; a
|
|
53
|
+
* `<major>.<minor>` form is "past" once the host already serves a version at or
|
|
54
|
+
* beyond it (the deprecation horizon has been reached on this very host).
|
|
55
|
+
*/
|
|
56
|
+
function untilInPast(until: string, served: string[]): boolean {
|
|
57
|
+
if (DATE.test(until)) return until < new Date().toISOString().slice(0, 10);
|
|
58
|
+
const [maj, min] = until.split('.').map(Number) as [number, number];
|
|
59
|
+
return served.some((v) => {
|
|
60
|
+
const [a, b] = v.split('.').map(Number) as [number, number];
|
|
61
|
+
return a > maj || (a === maj && b >= min);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('v2 capability-record-shape (RFC 0169 §A.1–§A.3)', () => {
|
|
66
|
+
it('every family record carries status, since and witness from the wire-legal sets', async () => {
|
|
67
|
+
const doc = await discovery();
|
|
68
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
69
|
+
const recs = records(doc);
|
|
70
|
+
if (recs.length === 0) return softSkip('inapplicable', 'the host advertises no family record at the v2 root (metadata only)');
|
|
71
|
+
for (const [key, rec] of recs) {
|
|
72
|
+
expect(STATUS.has(String(rec['status'])), req('openwop.requirement.0169.capability-record-shape.required-fields', DOC, `${key}.status MUST be one of stable | experimental | deprecated`)).toBe(true);
|
|
73
|
+
expect(typeof rec['since'] === 'string' && VERSION.test(rec['since']), req('openwop.requirement.0169.capability-record-shape.required-fields', DOC, `${key}.since MUST be present as <major>.<minor>`)).toBe(true);
|
|
74
|
+
expect(WITNESS.has(String(rec['witness'])), req('openwop.requirement.0169.capability-record-shape.required-fields', DOC, `${key}.witness MUST be one of the five wire-legal classes (unwitnessable MUST NOT appear on a wire record)`)).toBe(true);
|
|
75
|
+
expect('supported' in rec, req('openwop.requirement.0169.capability-record-shape.required-fields', DOC, `${key}: \`supported\` does not exist in v2 — presence of the record is the claim (§A.2)`)).toBe(false);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('`until` is present iff the record is experimental or deprecated', async () => {
|
|
80
|
+
const doc = await discovery();
|
|
81
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
82
|
+
const recs = records(doc);
|
|
83
|
+
if (recs.length === 0) return softSkip('inapplicable', 'the host advertises no family record at the v2 root (metadata only)');
|
|
84
|
+
for (const [key, rec] of recs) {
|
|
85
|
+
const hasUntil = rec['until'] !== undefined;
|
|
86
|
+
const stable = rec['status'] === 'stable';
|
|
87
|
+
expect(hasUntil, req('openwop.requirement.0169.capability-record-shape.until-iff-not-stable', DOC, `${key}: \`until\` is REQUIRED when status is experimental or deprecated and MUST NOT be present when stable (status=${String(rec['status'])})`)).toBe(!stable);
|
|
88
|
+
if (hasUntil) {
|
|
89
|
+
expect(typeof rec['until'] === 'string' && (VERSION.test(rec['until']) || DATE.test(rec['until'])), req('openwop.requirement.0169.capability-record-shape.until-iff-not-stable', DOC, `${key}.until MUST be <major>.<minor> or YYYY-MM-DD`)).toBe(true);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('no advertised record carries an `until` already in the past', async () => {
|
|
95
|
+
const doc = await discovery();
|
|
96
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
97
|
+
const served = Array.isArray(doc['protocolVersions']) ? (doc['protocolVersions'] as unknown[]).filter((v): v is string => typeof v === 'string') : [];
|
|
98
|
+
const dated = records(doc).filter(([, rec]) => typeof rec['until'] === 'string');
|
|
99
|
+
if (dated.length === 0) return softSkip('inapplicable', 'no advertised family record carries an `until` (every record is stable, or none is advertised)');
|
|
100
|
+
for (const [key, rec] of dated) {
|
|
101
|
+
expect(untilInPast(rec['until'] as string, served), req('openwop.requirement.0169.capability-record-shape.until-not-past', 'spec/v2/core/capabilities.md §2 (`until` in the past)', `${key}.until=${String(rec['until'])} is in the past — the document is non-conformant (a validator answers 400 until_in_past)`)).toBe(false);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-chain-pin-exact — RFC 0177 §E.1; `spec/v2/core/workflow-chain-packs.md`
|
|
3
|
+
* §"Exact pins".
|
|
4
|
+
*
|
|
5
|
+
* Suite 2.0.0, target major 2. Every reference a chain makes to a node type
|
|
6
|
+
* MUST pin an exact version per referenced `typeId` (`core.ai.callPrompt@1.0.0`);
|
|
7
|
+
* a host MUST refuse to register a chain whose reference carries a range or no
|
|
8
|
+
* version. Ranges are a v2.x additive follow-up.
|
|
9
|
+
*
|
|
10
|
+
* 1. a chain manifest referencing `core.ai.callPrompt@^1` is refused at
|
|
11
|
+
* register (4xx with an error code).
|
|
12
|
+
* 2. the same chain referencing `core.ai.callPrompt@1.0.0` registers.
|
|
13
|
+
*
|
|
14
|
+
* Both go through the seams-profile publish seam
|
|
15
|
+
* (`/conformance/seams/packs-test/…`, RFC 0168 §C.2), gated on
|
|
16
|
+
* `workflowChainPacks` + the seams profile. The exact-pin manifest is first
|
|
17
|
+
* validated against `schemas/v2/workflow-chain-pack-manifest.schema.json`: the
|
|
18
|
+
* schema's `FragmentNode.typeId` pattern has no room for `@<version>` and no
|
|
19
|
+
* sibling pin field exists, so while that gap stands the legs record `blocked`
|
|
20
|
+
* naming it (RFC 0177 §E.1 schema follow-up) rather than a pass.
|
|
21
|
+
*
|
|
22
|
+
* @see RFCS/0177-v2-registry-packs-and-extension-tail.md §E.1
|
|
23
|
+
* @see spec/v2/core/workflow-chain-packs.md §"Exact pins"
|
|
24
|
+
* @see schemas/v2/workflow-chain-pack-manifest.schema.json
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { gzipSync } from 'node:zlib';
|
|
29
|
+
import { driver } from '../lib/driver.js';
|
|
30
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
31
|
+
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
|
|
33
|
+
import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
|
|
34
|
+
import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
|
|
35
|
+
|
|
36
|
+
const SECTION = 'workflow-chain-packs.md §"Exact pins" (RFC 0177 §E.1)';
|
|
37
|
+
const SCHEMA_GAP = 'workflow-chain-pack-manifest.schema.json carries no per-typeId version pin (FragmentNode.typeId rejects `core.ai.callPrompt@1.0.0` and no pin field exists) — RFC 0177 §E.1 schema follow-up';
|
|
38
|
+
|
|
39
|
+
function tarEntry(name: string, data: Buffer): Buffer {
|
|
40
|
+
const h = Buffer.alloc(512, 0);
|
|
41
|
+
h.write(name, 0, 100, 'utf8');
|
|
42
|
+
h.write('0000644\0', 100, 8, 'utf8');
|
|
43
|
+
h.write('0000000\0', 108, 8, 'utf8');
|
|
44
|
+
h.write('0000000\0', 116, 8, 'utf8');
|
|
45
|
+
h.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124, 12, 'utf8');
|
|
46
|
+
h.write('00000000000\0', 136, 12, 'utf8');
|
|
47
|
+
h.write(' ', 148, 8, 'utf8');
|
|
48
|
+
h.write('0', 156, 1, 'utf8');
|
|
49
|
+
h.write('ustar\0', 257, 6, 'utf8');
|
|
50
|
+
h.write('00', 263, 2, 'utf8');
|
|
51
|
+
let sum = 0;
|
|
52
|
+
for (const b of h) sum += b;
|
|
53
|
+
h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'utf8');
|
|
54
|
+
const pad = (512 - (data.length % 512)) % 512;
|
|
55
|
+
return Buffer.concat([h, data, Buffer.alloc(pad, 0)]);
|
|
56
|
+
}
|
|
57
|
+
function packTarball(files: Record<string, string>): Buffer {
|
|
58
|
+
return gzipSync(Buffer.concat([...Object.entries(files).map(([n, c]) => tarEntry(n, Buffer.from(c, 'utf8'))), Buffer.alloc(1024, 0)]));
|
|
59
|
+
}
|
|
60
|
+
function freshName(slug: string): string {
|
|
61
|
+
return `core.openwop.v2-${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
62
|
+
}
|
|
63
|
+
function chainPack(name: string, reference: string): Record<string, unknown> {
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
version: '1.0.0',
|
|
67
|
+
kind: 'workflow-chain',
|
|
68
|
+
engines: { openwop: '>=2.0.0 <3.0.0' },
|
|
69
|
+
chains: [{
|
|
70
|
+
chainId: `${name}.chain`,
|
|
71
|
+
version: '1.0.0',
|
|
72
|
+
label: 'Pin fixture',
|
|
73
|
+
description: 'A one-node chain whose only reference is the pin under test.',
|
|
74
|
+
parameters: { type: 'object', properties: {} },
|
|
75
|
+
dag: { nodes: [{ id: 'n1', typeId: reference }], edges: [] },
|
|
76
|
+
}],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function publish(manifest: Record<string, unknown>) {
|
|
80
|
+
const name = manifest['name'] as string;
|
|
81
|
+
const version = manifest['version'] as string;
|
|
82
|
+
return driver.put(`/v1/packs-test/${encodeURIComponent(name)}/-/${encodeURIComponent(version)}.tgz`, packTarball({ 'pack.json': JSON.stringify(manifest, null, 2) }), {
|
|
83
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
|
|
87
|
+
if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
|
|
88
|
+
let doc: Record<string, unknown> | null;
|
|
89
|
+
try {
|
|
90
|
+
doc = await v2Discovery();
|
|
91
|
+
} catch {
|
|
92
|
+
doc = null;
|
|
93
|
+
}
|
|
94
|
+
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
95
|
+
if (!(await gateFamily('workflowChainPacks'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the workflowChainPacks family (RFC 0169 §A.2)' };
|
|
96
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'blocked', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only register path the suite can drive (RFC 0168 §C.1)' };
|
|
97
|
+
if (!v2Validator('workflow-chain-pack-manifest')(chainPack('core.openwop.pin-probe', 'core.ai.callPrompt@1.0.0')).ok) return { kind: 'blocked', reason: SCHEMA_GAP };
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('v2-chain-pin-exact (RFC 0177 §E.1)', () => {
|
|
102
|
+
it('a chain reference carrying a range (core.ai.callPrompt@^1) is refused at register', async () => {
|
|
103
|
+
const skip = await preflight();
|
|
104
|
+
if (skip) return softSkip(skip.kind, skip.reason);
|
|
105
|
+
const res = await publish(chainPack(freshName('chain-range'), 'core.ai.callPrompt@^1'));
|
|
106
|
+
if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
|
|
107
|
+
expect(res.status >= 400 && res.status < 500, req('openwop.requirement.0177.chain-pin-exact.range-refused', SECTION, `a ranged reference MUST be refused at register (got ${res.status})`)).toBe(true);
|
|
108
|
+
expect(typeof readErrorCode(res.json), req('openwop.requirement.0177.chain-pin-exact.range-refused', SECTION, 'the refusal MUST carry an error code in the canonical envelope')).toBe('string');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('a chain reference pinning an exact version (core.ai.callPrompt@1.0.0) registers', async () => {
|
|
112
|
+
const skip = await preflight();
|
|
113
|
+
if (skip) return softSkip(skip.kind, skip.reason);
|
|
114
|
+
const res = await publish(chainPack(freshName('chain-exact'), 'core.ai.callPrompt@1.0.0'));
|
|
115
|
+
if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
|
|
116
|
+
expect([200, 201].includes(res.status), req('openwop.requirement.0177.chain-pin-exact.exact-accepted', SECTION, `an exact pin MUST register (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|