@openwop/openwop-conformance 2.42.8 → 2.43.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 +21 -0
- package/README.md +3 -3
- package/coverage.md +1 -1
- package/dist/cli.js +3 -4
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures.md +2 -1
- package/package.json +2 -2
- package/requirements.json +374 -33
- package/scenario-majors.json +17 -5
- package/schemas/CORPUS-STAMP.json +37 -36
- package/src/cli.ts +3 -4
- package/src/lib/auditIntegrity.ts +45 -0
- package/src/lib/mock-ai-budgets.ts +26 -0
- package/src/lib/mock-node-lock.ts +66 -0
- package/src/scenarios/audit-anomaly-shape.test.ts +100 -0
- package/src/scenarios/audit-checkpoint-signature.test.ts +14 -15
- package/src/scenarios/audit-log-integrity.test.ts +102 -46
- package/src/scenarios/byok-roundtrip.test.ts +21 -7
- package/src/scenarios/envelope-completion-distinguishes-truncation.test.ts +17 -8
- package/src/scenarios/envelope-retry-attempted.test.ts +4 -0
- package/src/scenarios/envelope-truncated.test.ts +16 -16
- package/src/scenarios/v2-approval-reject-disposition.test.ts +162 -0
- package/src/scenarios/v2-registry-lifecycle.test.ts +232 -0
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +5 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0222 — v2 registry lifecycle and signing keys, witnessed against a
|
|
3
|
+
* registry (`spec/v2/core/packs.md` §"Version manifests" and §Signing; suite
|
|
4
|
+
* 2.43.0, target major 2).
|
|
5
|
+
*
|
|
6
|
+
* This scenario reads a REGISTRY, not the host under test. It runs only when
|
|
7
|
+
* one is named: `OPENWOP_REGISTRY_URL=<origin>` (a mirror, a vendor registry,
|
|
8
|
+
* or `node registry/scripts/serve.mjs` in openwop-registry), or
|
|
9
|
+
* `OPENWOP_TEST_PUBLIC_REGISTRY=true` for `https://packs.openwop.dev`. With
|
|
10
|
+
* neither it records `inapplicable`, like `registry-public.test.ts`: a host
|
|
11
|
+
* conformance run MUST NOT need outbound access to a registry. It is a major-2
|
|
12
|
+
* file, so it runs in a major-2 lane (`OPENWOP_TARGET_MAJOR=2`).
|
|
13
|
+
*
|
|
14
|
+
* Every path is resolved through `.well-known/openwop-registry` `endpoints.v2`
|
|
15
|
+
* (packs.md §"The registry tree"), never constructed.
|
|
16
|
+
*
|
|
17
|
+
* How it FAILS:
|
|
18
|
+
* - a per-pack index names a yanked version `latest` while the pack has one
|
|
19
|
+
* that is not yanked, or disagrees with the version manifest's `yanked` /
|
|
20
|
+
* `versionDeprecated`, or the registry-wide row disagrees with it;
|
|
21
|
+
* - a yanked version's manifest, tarball or signature is no longer served;
|
|
22
|
+
* - a served version names a key that `signingKeys[]` no longer lists, or one
|
|
23
|
+
* whose `permittedNamespaces` does not admit the pack, or whose signature
|
|
24
|
+
* does not verify. A key that is no longer `active` still verifies what it
|
|
25
|
+
* signed; the scenario refuses only a key that disappeared.
|
|
26
|
+
*
|
|
27
|
+
* @see spec/v2/core/packs.md §"Version manifests", §Signing
|
|
28
|
+
* @see RFCS/0222-v2-registry-operations.md
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
33
|
+
import { gunzipSync } from 'node:zlib';
|
|
34
|
+
import { req } from '../lib/requirement-ids.js';
|
|
35
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
36
|
+
|
|
37
|
+
type Json = Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
const ID_YANK = 'openwop.requirement.0222.yanked-version-lifecycle';
|
|
40
|
+
const ID_KEYS = 'openwop.requirement.0222.signing-keys-cover-served-versions';
|
|
41
|
+
const SPEC_VM = 'RFC 0222 · packs.md §"Version manifests"';
|
|
42
|
+
const SPEC_SIG = 'RFC 0222 · packs.md §Signing';
|
|
43
|
+
|
|
44
|
+
const BASE = (process.env['OPENWOP_REGISTRY_URL'] ?? (process.env['OPENWOP_TEST_PUBLIC_REGISTRY'] === 'true' ? 'https://packs.openwop.dev' : '')).replace(/\/+$/, '');
|
|
45
|
+
const NO_REGISTRY = 'no registry named — set OPENWOP_REGISTRY_URL (or OPENWOP_TEST_PUBLIC_REGISTRY=true for packs.openwop.dev); a host run does not read a registry';
|
|
46
|
+
|
|
47
|
+
async function fetchWithRetry(url: string): Promise<Response> {
|
|
48
|
+
for (let attempt = 0; ; attempt++) {
|
|
49
|
+
try {
|
|
50
|
+
return await fetch(url, { headers: { Accept: 'application/json, */*' } });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (attempt >= 2) throw err;
|
|
53
|
+
await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getJson(path: string): Promise<{ status: number; json: Json | null }> {
|
|
59
|
+
const res = await fetchWithRetry(`${BASE}${path}`);
|
|
60
|
+
if (res.status !== 200) return { status: res.status, json: null };
|
|
61
|
+
try {
|
|
62
|
+
return { status: 200, json: (await res.json()) as Json };
|
|
63
|
+
} catch {
|
|
64
|
+
return { status: 200, json: null };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function getBytes(path: string): Promise<{ status: number; bytes: Buffer }> {
|
|
69
|
+
const res = await fetchWithRetry(`${BASE}${path}`);
|
|
70
|
+
return { status: res.status, bytes: Buffer.from(await res.arrayBuffer()) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Run `fn` over `items`, `width` at a time. */
|
|
74
|
+
async function pool<T, R>(items: readonly T[], width: number, fn: (t: T) => Promise<R>): Promise<R[]> {
|
|
75
|
+
const out: R[] = new Array(items.length);
|
|
76
|
+
let next = 0;
|
|
77
|
+
await Promise.all(Array.from({ length: Math.min(width, items.length) }, async () => {
|
|
78
|
+
for (let i = next++; i < items.length; i = next++) out[i] = await fn(items[i]!);
|
|
79
|
+
}));
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The exact bytes of `pack.json` inside a gzipped USTAR tarball (what a v2 signature covers). */
|
|
84
|
+
function packJsonBytes(tgz: Buffer): Buffer | null {
|
|
85
|
+
const tar = gunzipSync(tgz);
|
|
86
|
+
for (let off = 0; off + 512 <= tar.length; ) {
|
|
87
|
+
const nameBuf = tar.subarray(off, off + 100);
|
|
88
|
+
const end = nameBuf.indexOf(0);
|
|
89
|
+
const name = nameBuf.subarray(0, end < 0 ? 100 : end).toString('utf8');
|
|
90
|
+
if (!name) return null;
|
|
91
|
+
const size = parseInt(tar.subarray(off + 124, off + 136).toString('ascii').replace(/\0/g, '').trim() || '0', 8);
|
|
92
|
+
if (name === 'pack.json' || name === './pack.json' || name === 'package/pack.json') return tar.subarray(off + 512, off + 512 + size);
|
|
93
|
+
off += 512 + Math.ceil(size / 512) * 512;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fill(template: string, name: string, version?: string): string {
|
|
99
|
+
return template.replace('{name}', name).replace('{version}', version ?? '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Registry {
|
|
103
|
+
readonly discovery: Json;
|
|
104
|
+
readonly v2: Record<string, string>;
|
|
105
|
+
readonly packs: Json[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let cached: Promise<Registry | string> | null = null;
|
|
109
|
+
|
|
110
|
+
/** Discovery + the v2 index, or the reason the registry is unreadable (an assertion failure, not a skip). */
|
|
111
|
+
function registry(): Promise<Registry | string> {
|
|
112
|
+
cached ??= (async () => {
|
|
113
|
+
const wk = await getJson('/.well-known/openwop-registry');
|
|
114
|
+
if (!wk.json) return `GET /.well-known/openwop-registry answered ${wk.status} without a JSON document`;
|
|
115
|
+
const endpoints = (wk.json['endpoints'] ?? {}) as Json;
|
|
116
|
+
const v2 = endpoints['v2'] as Record<string, string> | undefined;
|
|
117
|
+
if (!v2 || typeof v2['registryIndex'] !== 'string' || typeof v2['packMetadata'] !== 'string' || typeof v2['versionManifest'] !== 'string') {
|
|
118
|
+
return 'the discovery document has no endpoints.v2 {registryIndex, packMetadata, versionManifest}';
|
|
119
|
+
}
|
|
120
|
+
const idx = await getJson(v2['registryIndex']);
|
|
121
|
+
if (!idx.json || !Array.isArray(idx.json['packs'])) return `GET ${v2['registryIndex']} answered ${idx.status} without a packs[] index`;
|
|
122
|
+
return { discovery: wk.json, v2, packs: idx.json['packs'] as Json[] };
|
|
123
|
+
})();
|
|
124
|
+
return cached;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface Version { readonly pack: string; readonly version: string; readonly manifest: Json }
|
|
128
|
+
|
|
129
|
+
describe('RFC 0222 — v2 registry lifecycle and signing keys', () => {
|
|
130
|
+
it('a yanked version is never latest while an unyanked one exists, the flags agree across index and manifest, and a yanked version stays served', async () => {
|
|
131
|
+
if (!BASE) return softSkip('inapplicable', NO_REGISTRY);
|
|
132
|
+
const reg = await registry();
|
|
133
|
+
expect(typeof reg, req(ID_YANK, SPEC_VM, `the registry MUST serve a v2 index through endpoints.v2 — ${typeof reg === 'string' ? reg : ''}`)).toBe('object');
|
|
134
|
+
if (typeof reg === 'string') throw new Error(reg);
|
|
135
|
+
|
|
136
|
+
const problems: string[] = [];
|
|
137
|
+
const yanked: Version[] = [];
|
|
138
|
+
await pool(reg.packs, 8, async (row) => {
|
|
139
|
+
const name = String(row['name']);
|
|
140
|
+
const meta = await getJson(fill(reg.v2['packMetadata']!, name));
|
|
141
|
+
if (!meta.json) { problems.push(`${name}: pack metadata answered ${meta.status}`); return; }
|
|
142
|
+
const versions = (Array.isArray(meta.json['versions']) ? meta.json['versions'] : []) as Json[];
|
|
143
|
+
const unyanked = versions.filter((v) => v['yanked'] !== true).map((v) => String(v['version']));
|
|
144
|
+
const latest = String(meta.json['latest']);
|
|
145
|
+
const latestRow = versions.find((v) => v['version'] === latest);
|
|
146
|
+
if (latestRow?.['yanked'] === true && unyanked.length > 0) problems.push(`${name}: latest ${latest} is yanked while ${unyanked.join(', ')} is not`);
|
|
147
|
+
if (row['latestVersion'] !== latest) problems.push(`${name}: registry index latestVersion ${String(row['latestVersion'])} ≠ pack metadata latest ${latest}`);
|
|
148
|
+
if (row['yanked'] === true && unyanked.length > 0) problems.push(`${name}: registry index marks the pack yanked while ${unyanked.join(', ')} is not`);
|
|
149
|
+
for (const v of versions) {
|
|
150
|
+
const version = String(v['version']);
|
|
151
|
+
const vm = await getJson(fill(reg.v2['versionManifest']!, name, version));
|
|
152
|
+
if (!vm.json) { problems.push(`${name}@${version}: version manifest answered ${vm.status}`); continue; }
|
|
153
|
+
if ((vm.json['yanked'] === true) !== (v['yanked'] === true)) problems.push(`${name}@${version}: index yanked=${String(v['yanked'])} but manifest yanked=${String(vm.json['yanked'])}`);
|
|
154
|
+
if ((vm.json['versionDeprecated'] === true) !== (v['versionDeprecated'] === true)) problems.push(`${name}@${version}: index versionDeprecated=${String(v['versionDeprecated'])} but manifest versionDeprecated=${String(vm.json['versionDeprecated'])}`);
|
|
155
|
+
if (vm.json['yanked'] === true) yanked.push({ pack: name, version, manifest: vm.json });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
expect(problems, req(ID_YANK, SPEC_VM, 'the per-pack index MUST NOT name a yanked version latest while an unyanked one exists, and the index MUST agree with each version manifest')).toEqual([]);
|
|
159
|
+
|
|
160
|
+
if (yanked.length === 0) {
|
|
161
|
+
// partial-witness-ok: the index/manifest agreement was asserted over every
|
|
162
|
+
// version; the served-after-yank half has nothing to observe.
|
|
163
|
+
return softSkip('inapplicable', 'no version in this v2 tree is yanked — the latest rule held vacuously and there is no yanked version whose files could be checked');
|
|
164
|
+
}
|
|
165
|
+
const unserved: string[] = [];
|
|
166
|
+
await pool(yanked, 8, async ({ pack, version }) => {
|
|
167
|
+
for (const key of ['versionTarball', 'versionSignature'] as const) {
|
|
168
|
+
const tmpl = reg.v2[key];
|
|
169
|
+
if (typeof tmpl !== 'string') { unserved.push(`endpoints.v2.${key} is absent`); continue; }
|
|
170
|
+
const r = await getBytes(fill(tmpl, pack, version));
|
|
171
|
+
if (r.status !== 200 || r.bytes.byteLength === 0) unserved.push(`${pack}@${version}: ${key} answered ${r.status}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
expect(unserved, req(ID_YANK, SPEC_VM, `the registry MUST keep serving a yanked version's manifest, tarball and signature at their exact paths (${yanked.length} yanked)`)).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('every served version names a listed key whose permittedNamespaces admit it, and its signature verifies — whatever the key status', async () => {
|
|
178
|
+
if (!BASE) return softSkip('inapplicable', NO_REGISTRY);
|
|
179
|
+
const reg = await registry();
|
|
180
|
+
expect(typeof reg, req(ID_KEYS, SPEC_SIG, `the registry MUST serve a v2 index through endpoints.v2 — ${typeof reg === 'string' ? reg : ''}`)).toBe('object');
|
|
181
|
+
if (typeof reg === 'string') throw new Error(reg);
|
|
182
|
+
|
|
183
|
+
const keys = (Array.isArray(reg.discovery['signingKeys']) ? reg.discovery['signingKeys'] : []) as Json[];
|
|
184
|
+
const malformed = keys.filter((k) => typeof k['keyId'] !== 'string' || typeof k['publicKeyUrl'] !== 'string' || typeof k['status'] !== 'string' || !Array.isArray(k['permittedNamespaces']));
|
|
185
|
+
expect(malformed.map((k) => String(k['keyId'])), req(ID_KEYS, SPEC_SIG, 'every signingKeys[] entry MUST carry keyId, publicKeyUrl, status and permittedNamespaces')).toEqual([]);
|
|
186
|
+
const byId = new Map(keys.map((k) => [String(k['keyId']), k]));
|
|
187
|
+
const publicKeys = new Map<string, ReturnType<typeof createPublicKey> | null>();
|
|
188
|
+
|
|
189
|
+
const permits = (key: Json, name: string): boolean => (key['permittedNamespaces'] as unknown[]).some((p) => {
|
|
190
|
+
const pat = String(p);
|
|
191
|
+
return pat.endsWith('.*') ? name.startsWith(pat.slice(0, -1)) : pat === name;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const versions: Version[] = [];
|
|
195
|
+
await pool(reg.packs, 8, async (row) => {
|
|
196
|
+
const name = String(row['name']);
|
|
197
|
+
const meta = await getJson(fill(reg.v2['packMetadata']!, name));
|
|
198
|
+
for (const v of (Array.isArray(meta.json?.['versions']) ? meta.json!['versions'] : []) as Json[]) {
|
|
199
|
+
const vm = await getJson(fill(reg.v2['versionManifest']!, name, String(v['version'])));
|
|
200
|
+
if (vm.json) versions.push({ pack: name, version: String(v['version']), manifest: vm.json });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
expect(versions.length, req(ID_KEYS, SPEC_SIG, 'the v2 tree MUST serve at least one version manifest to witness')).toBeGreaterThan(0);
|
|
204
|
+
|
|
205
|
+
const problems: string[] = [];
|
|
206
|
+
const statuses = new Map<string, number>();
|
|
207
|
+
await pool(versions, 8, async ({ pack, version, manifest }) => {
|
|
208
|
+
const at = `${pack}@${version}`;
|
|
209
|
+
const keyId = String((manifest['signing'] as Json | undefined)?.['keyId'] ?? '');
|
|
210
|
+
const key = byId.get(keyId);
|
|
211
|
+
if (!key) { problems.push(`${at}: signed by ${keyId || '(no keyId)'}, which signingKeys[] does not list`); return; }
|
|
212
|
+
if (!permits(key, pack)) { problems.push(`${at}: key ${keyId} does not permit this namespace (${(key['permittedNamespaces'] as unknown[]).join(', ')})`); return; }
|
|
213
|
+
statuses.set(String(key['status']), (statuses.get(String(key['status'])) ?? 0) + 1);
|
|
214
|
+
if (!publicKeys.has(keyId)) {
|
|
215
|
+
const r = await getBytes(String(key['publicKeyUrl']));
|
|
216
|
+
let pk: ReturnType<typeof createPublicKey> | null = null;
|
|
217
|
+
try { pk = r.status === 200 ? createPublicKey(r.bytes.toString('utf8')) : null; } catch { pk = null; }
|
|
218
|
+
publicKeys.set(keyId, pk);
|
|
219
|
+
}
|
|
220
|
+
const pk = publicKeys.get(keyId);
|
|
221
|
+
if (!pk) { problems.push(`${at}: publicKeyUrl for ${keyId} does not serve a public key`); return; }
|
|
222
|
+
const tgz = await getBytes(fill(reg.v2['versionTarball'] ?? '', pack, version));
|
|
223
|
+
const sig = await getBytes(fill(reg.v2['versionSignature'] ?? '', pack, version));
|
|
224
|
+
if (tgz.status !== 200 || sig.status !== 200) { problems.push(`${at}: tarball ${tgz.status}, signature ${sig.status}`); return; }
|
|
225
|
+
if (typeof manifest['integrity'] === 'string' && manifest['integrity'] !== `sha256-${createHash('sha256').update(tgz.bytes).digest('base64')}`) problems.push(`${at}: integrity does not match the served tarball`);
|
|
226
|
+
const signed = packJsonBytes(tgz.bytes);
|
|
227
|
+
if (!signed) { problems.push(`${at}: the tarball has no pack.json`); return; }
|
|
228
|
+
if (!cryptoVerify(null, signed, pk, sig.bytes)) problems.push(`${at}: the signature does not verify under ${keyId}`);
|
|
229
|
+
});
|
|
230
|
+
expect(problems, req(ID_KEYS, SPEC_SIG, `every served version MUST name a listed key permitted for its namespace and verify under it, whatever the key status (${versions.length} versions; key status ${JSON.stringify(Object.fromEntries(statuses))})`)).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -234,6 +234,11 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
234
234
|
expect(owner !== null, req(ID, DOC, 'a major-2 run.started payload MUST carry the owner echo { tenant, subject } (identity.md §1; run-event-payloads runStarted.owner requires both)')).toBe(true);
|
|
235
235
|
expect(Object.keys(owner ?? {}).filter((k) => k === 'principal' || k === 'principalKind'), req(ID, DOC, 'a major-2 owner echo MUST NOT carry the v1 keys principal / principalKind — that is the fan-out forwarding the in-process dialect instead of projecting')).toEqual([]);
|
|
236
236
|
expect(typeof owner?.['tenant'] === 'string' && owner?.['subject'] !== undefined, req(ID, DOC, 'a major-2 owner echo carries tenant and subject')).toBe(true);
|
|
237
|
+
// webhooks.md §Delivery: `workspaceId` is present exactly when RunSnapshot.owner.workspace is,
|
|
238
|
+
// equal to it, and a host MUST NOT substitute its tenant id (or any placeholder) for an absent one.
|
|
239
|
+
const ws = owner?.['workspace'];
|
|
240
|
+
const sent = (d.envelope as Record<string, unknown>)['workspaceId'];
|
|
241
|
+
expect(ws === undefined ? sent === undefined : sent === ws, req(ID, DOC, `workspaceId MUST be present exactly when owner.workspace is, and equal to it — nothing is substituted for an absent workspace (owner.workspace ${JSON.stringify(ws)}, delivered workspaceId ${JSON.stringify(sent)})`)).toBe(true);
|
|
237
242
|
});
|
|
238
243
|
|
|
239
244
|
it('a major-1 subscriber still receives the v1 rendering — the v1 wire does not move mid-overlap', async () => {
|