@openwop/openwop-conformance 1.0.0 → 1.1.1
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 +17 -0
- package/README.md +31 -6
- package/api/grpc/openwop.proto +251 -0
- package/api/openapi.yaml +109 -3
- package/coverage.md +48 -9
- package/fixtures/conformance-configurable-schema.json +39 -0
- package/fixtures/conformance-subworkflow-parent.json +1 -1
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
- package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
- package/fixtures.md +21 -0
- package/package.json +3 -1
- package/schemas/README.md +4 -0
- package/schemas/audit-verify-result.schema.json +90 -0
- package/schemas/capabilities.schema.json +342 -1
- package/schemas/node-pack-manifest.schema.json +4 -4
- package/schemas/pack-lockfile.schema.json +92 -0
- package/schemas/registry-version-manifest.schema.json +145 -0
- package/schemas/run-event-payloads.schema.json +20 -4
- package/schemas/run-event.schema.json +2 -1
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +107 -0
- package/src/lib/env.ts +37 -0
- package/src/lib/grpc-framing.test.ts +96 -0
- package/src/lib/grpc-framing.ts +76 -0
- package/src/lib/oidc-issuer.test.ts +328 -0
- package/src/lib/oidc-issuer.ts +241 -0
- package/src/lib/otel-collector-grpc.test.ts +191 -0
- package/src/lib/otel-collector.test.ts +303 -0
- package/src/lib/otel-collector.ts +318 -14
- package/src/lib/otlp-protobuf.test.ts +461 -0
- package/src/lib/otlp-protobuf.ts +529 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
- package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
- package/src/scenarios/agentMessageReducer.test.ts +1 -0
- package/src/scenarios/agentMetadata.test.ts +1 -0
- package/src/scenarios/agentPackExport.test.ts +1 -0
- package/src/scenarios/agentPackInstall.test.ts +1 -0
- package/src/scenarios/agentPackProvenance.test.ts +1 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -6
- package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
- package/src/scenarios/auth-mtls.test.ts +274 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
- package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
- package/src/scenarios/bulk-cancel.test.ts +111 -0
- package/src/scenarios/configurable-schema.test.ts +48 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
- package/src/scenarios/conversationLifecycle.test.ts +1 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
- package/src/scenarios/discovery.test.ts +183 -0
- package/src/scenarios/http-client-ssrf.test.ts +71 -0
- package/src/scenarios/idempotency.test.ts +6 -0
- package/src/scenarios/idempotencyRetry.test.ts +3 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- package/src/scenarios/multi-region-idempotency.test.ts +39 -4
- package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
- package/src/scenarios/orchestratorDispatch.test.ts +1 -0
- package/src/scenarios/orchestratorTermination.test.ts +1 -0
- package/src/scenarios/otel-emission-grpc.test.ts +98 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
- package/src/scenarios/pause-resume.test.ts +119 -0
- package/src/scenarios/production-backpressure.test.ts +342 -0
- package/src/scenarios/production-retention-expiry.test.ts +164 -0
- package/src/scenarios/registry-public.test.ts +222 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
- package/src/scenarios/replay-retention-expiry.test.ts +178 -0
- package/src/scenarios/restart-during-run.test.ts +177 -0
- package/src/scenarios/spec-corpus-validity.test.ts +59 -26
- package/src/scenarios/staleClaim.test.ts +3 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
- package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
- package/src/scenarios/webhook-negative.test.ts +90 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
- package/src/setup.ts +25 -1
- package/vitest.config.ts +5 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0009 §C: production-profile event-retention expiry.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that hosts claiming the `openwop-production` profile satisfy
|
|
5
|
+
* `spec/v1/production-profile.md` §"Event retention":
|
|
6
|
+
*
|
|
7
|
+
* 1. `capabilities.production.retention.supported: true` is advertised.
|
|
8
|
+
* 2. `capabilities.production.retention.minWindowSeconds >= 604800`
|
|
9
|
+
* (7 days) — the minimum retention window for public hosts.
|
|
10
|
+
* 3. `GET /v1/runs/{expiredRunId}` on an expired run returns `410 Gone`
|
|
11
|
+
* (preferred) or `404 Not Found` per spec, with the canonical
|
|
12
|
+
* error envelope `{error, message, details?}`.
|
|
13
|
+
*
|
|
14
|
+
* Forcing expiry is host-private — the RFC defers endpoint normation
|
|
15
|
+
* (unresolved question #1). The scenario reads two env vars supplied
|
|
16
|
+
* by the operator running the suite:
|
|
17
|
+
*
|
|
18
|
+
* - `OPENWOP_TEST_EXPIRED_RUN_ID` — id of a pre-expired run the
|
|
19
|
+
* host has on file. Used by both the soft-skip and active paths.
|
|
20
|
+
* - `OPENWOP_TEST_FORCE_EXPIRE_URL` — optional host-private endpoint
|
|
21
|
+
* the suite POSTs to in order to evict a freshly-created run.
|
|
22
|
+
* Honored only when `capabilities.production.retention.testForceExpire: true`.
|
|
23
|
+
*
|
|
24
|
+
* When neither path is available, the scenario asserts only the
|
|
25
|
+
* capability shape and soft-skips the envelope check.
|
|
26
|
+
*
|
|
27
|
+
* @see RFCS/0009-production-profile-conformance.md §C
|
|
28
|
+
* @see spec/v1/production-profile.md §"Event retention"
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { driver } from '../lib/driver.js';
|
|
33
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
34
|
+
|
|
35
|
+
interface RetentionCaps {
|
|
36
|
+
supported?: boolean;
|
|
37
|
+
minWindowSeconds?: number;
|
|
38
|
+
testForceExpire?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ProductionCaps {
|
|
42
|
+
supported?: boolean;
|
|
43
|
+
retention?: RetentionCaps;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readProductionCaps(): Promise<ProductionCaps | undefined> {
|
|
47
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
48
|
+
return (disco.json as { capabilities?: { production?: ProductionCaps } })
|
|
49
|
+
.capabilities?.production;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isProfileAdvertised(prod: ProductionCaps | undefined): boolean {
|
|
53
|
+
return prod?.supported === true && prod?.retention?.supported === true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SEVEN_DAYS_SECONDS = 604800;
|
|
57
|
+
|
|
58
|
+
describe('production-retention-expiry: capability shape', () => {
|
|
59
|
+
it('host claiming openwop-production with retention advertises required fields', async () => {
|
|
60
|
+
const prod = await readProductionCaps();
|
|
61
|
+
|
|
62
|
+
if (!behaviorGate('openwop-production', isProfileAdvertised(prod))) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expect(prod?.retention?.supported, driver.describe(
|
|
67
|
+
'production-profile.md §"Event retention"',
|
|
68
|
+
'capabilities.production.retention.supported MUST be true for production-profile claimants',
|
|
69
|
+
)).toBe(true);
|
|
70
|
+
|
|
71
|
+
expect(prod?.retention?.minWindowSeconds, driver.describe(
|
|
72
|
+
'production-profile.md §"Event retention"',
|
|
73
|
+
'capabilities.production.retention.minWindowSeconds MUST be advertised when retention.supported is true',
|
|
74
|
+
)).toBeDefined();
|
|
75
|
+
|
|
76
|
+
expect(
|
|
77
|
+
Number.isInteger(prod?.retention?.minWindowSeconds) &&
|
|
78
|
+
(prod?.retention?.minWindowSeconds ?? 0) >= SEVEN_DAYS_SECONDS,
|
|
79
|
+
driver.describe(
|
|
80
|
+
'production-profile.md §"Event retention"',
|
|
81
|
+
'minWindowSeconds MUST be an integer ≥ 604800 (7 days) for public production-profile claimants',
|
|
82
|
+
),
|
|
83
|
+
).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('production-retention-expiry: 410/404 envelope on expired run', () => {
|
|
88
|
+
it('GET /v1/runs/{expiredRunId} returns 410 or 404 with canonical envelope', async () => {
|
|
89
|
+
const prod = await readProductionCaps();
|
|
90
|
+
|
|
91
|
+
if (!behaviorGate('openwop-production', isProfileAdvertised(prod))) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let expiredRunId = process.env.OPENWOP_TEST_EXPIRED_RUN_ID;
|
|
96
|
+
|
|
97
|
+
// Active expiry path: when the host advertises a test force-expire
|
|
98
|
+
// hook, create a fresh run and call the operator-supplied endpoint
|
|
99
|
+
// to evict it. The endpoint shape is host-private (RFC 0009 Q#1).
|
|
100
|
+
const forceExpireUrl = process.env.OPENWOP_TEST_FORCE_EXPIRE_URL;
|
|
101
|
+
const forceExpireMethod = process.env.OPENWOP_TEST_FORCE_EXPIRE_METHOD ?? 'POST';
|
|
102
|
+
|
|
103
|
+
if (
|
|
104
|
+
prod?.retention?.testForceExpire === true &&
|
|
105
|
+
forceExpireUrl !== undefined &&
|
|
106
|
+
expiredRunId === undefined
|
|
107
|
+
) {
|
|
108
|
+
// Create a throwaway run.
|
|
109
|
+
const create = await driver.post('/v1/runs', {
|
|
110
|
+
workflowId: 'conformance-noop',
|
|
111
|
+
});
|
|
112
|
+
if (create.status === 201) {
|
|
113
|
+
const newRunId = (create.json as { runId: string }).runId;
|
|
114
|
+
// Call the host-private force-expire endpoint. Operator wires
|
|
115
|
+
// this to whatever route the host exposes.
|
|
116
|
+
const url = forceExpireUrl.replace('{runId}', encodeURIComponent(newRunId));
|
|
117
|
+
const forced = await fetch(url, { method: forceExpireMethod });
|
|
118
|
+
if (forced.ok || forced.status === 204) {
|
|
119
|
+
expiredRunId = newRunId;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (expiredRunId === undefined) {
|
|
125
|
+
// eslint-disable-next-line no-console
|
|
126
|
+
console.warn(
|
|
127
|
+
'[production-retention-expiry] no expired runId available (set OPENWOP_TEST_EXPIRED_RUN_ID or advertise testForceExpire + provide OPENWOP_TEST_FORCE_EXPIRE_URL); skipping envelope assertion',
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(expiredRunId)}`);
|
|
133
|
+
|
|
134
|
+
expect(
|
|
135
|
+
res.status === 410 || res.status === 404,
|
|
136
|
+
driver.describe(
|
|
137
|
+
'production-profile.md §"Event retention"',
|
|
138
|
+
'expired run MUST return 410 Gone (preferred) or 404 Not Found',
|
|
139
|
+
),
|
|
140
|
+
).toBe(true);
|
|
141
|
+
|
|
142
|
+
const body = res.json as {
|
|
143
|
+
error?: string;
|
|
144
|
+
message?: string;
|
|
145
|
+
details?: { expiredAt?: string };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
expect(typeof body.error, driver.describe(
|
|
149
|
+
'production-profile.md §"Event retention"',
|
|
150
|
+
'expired-run response MUST use the canonical error envelope ({error, message, details?})',
|
|
151
|
+
)).toBe('string');
|
|
152
|
+
expect((body.error ?? '').length).toBeGreaterThan(0);
|
|
153
|
+
|
|
154
|
+
expect(typeof body.message).toBe('string');
|
|
155
|
+
expect((body.message ?? '').length).toBeGreaterThan(0);
|
|
156
|
+
|
|
157
|
+
// When the host returns 410, details.expiredAt is RECOMMENDED.
|
|
158
|
+
// Soft-check: when present, MUST be a non-empty string.
|
|
159
|
+
if (res.status === 410 && body.details?.expiredAt !== undefined) {
|
|
160
|
+
expect(typeof body.details.expiredAt).toBe('string');
|
|
161
|
+
expect(body.details.expiredAt.length).toBeGreaterThan(0);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-registry availability scenario — `packs.openwop.dev`.
|
|
3
|
+
*
|
|
4
|
+
* Unlike `pack-registry.test.ts` (which probes the host-under-test for an
|
|
5
|
+
* optional in-host registry), this scenario hits the *public, hosted*
|
|
6
|
+
* registry at `packs.openwop.dev` directly. Its purpose is to provide a
|
|
7
|
+
* single mechanical check that the public registry is up, serves the four
|
|
8
|
+
* documented endpoint shapes, and returns valid manifests for the
|
|
9
|
+
* spec-canonical packs currently published.
|
|
10
|
+
*
|
|
11
|
+
* Gating:
|
|
12
|
+
* This scenario is skipped by default — `@openwop/openwop-conformance`
|
|
13
|
+
* runs MUST NOT require outbound connectivity to `packs.openwop.dev`.
|
|
14
|
+
* Opt-in via `OPENWOP_TEST_PUBLIC_REGISTRY=true`.
|
|
15
|
+
*
|
|
16
|
+
* Why this lives in the conformance suite even though it's not a host
|
|
17
|
+
* conformance scenario:
|
|
18
|
+
* - It provides a one-command public-registry healthcheck for the
|
|
19
|
+
* project's own operations.
|
|
20
|
+
* - It documents (via assertions) the contract `packs.openwop.dev`
|
|
21
|
+
* promises to serve.
|
|
22
|
+
* - It reuses the same vitest scaffolding as the rest of the suite.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v1/registry-operations.md
|
|
25
|
+
* @see ROADMAP.md §"Hosted infrastructure"
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
30
|
+
|
|
31
|
+
const REGISTRY_BASE = 'https://packs.openwop.dev';
|
|
32
|
+
const ENABLED = process.env.OPENWOP_TEST_PUBLIC_REGISTRY === 'true';
|
|
33
|
+
|
|
34
|
+
const PACK_NAME_RE = /^(core|vendor|community|private)\.[a-z][a-z0-9_-]*(\.[a-z][a-zA-Z0-9_-]*)+$/;
|
|
35
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
36
|
+
|
|
37
|
+
async function get(path: string): Promise<{ status: number; json: unknown }> {
|
|
38
|
+
const res = await fetch(`${REGISTRY_BASE}${path}`, {
|
|
39
|
+
headers: { Accept: 'application/json' },
|
|
40
|
+
});
|
|
41
|
+
let json: unknown = undefined;
|
|
42
|
+
try {
|
|
43
|
+
json = await res.json();
|
|
44
|
+
} catch {
|
|
45
|
+
// body may not be JSON (e.g. tarball); caller handles.
|
|
46
|
+
}
|
|
47
|
+
return { status: res.status, json };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('registry-public: packs.openwop.dev discovery document', () => {
|
|
51
|
+
it('GET /.well-known/openwop-registry returns a valid discovery payload', async () => {
|
|
52
|
+
if (!ENABLED) {
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.warn(
|
|
55
|
+
'[registry-public] skipped — set OPENWOP_TEST_PUBLIC_REGISTRY=true to enable',
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const res = await get('/.well-known/openwop-registry');
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
|
|
63
|
+
const body = res.json as {
|
|
64
|
+
registryVersion?: string;
|
|
65
|
+
protocolVersion?: string;
|
|
66
|
+
url?: string;
|
|
67
|
+
supportedNamespaces?: string[];
|
|
68
|
+
supportedSigningMethods?: string[];
|
|
69
|
+
endpoints?: Record<string, string>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
expect(body.registryVersion).toBe('1.0.0');
|
|
73
|
+
expect(body.protocolVersion).toBe('1.0');
|
|
74
|
+
expect(typeof body.url).toBe('string');
|
|
75
|
+
expect(Array.isArray(body.supportedNamespaces)).toBe(true);
|
|
76
|
+
expect(body.supportedNamespaces).toEqual(
|
|
77
|
+
expect.arrayContaining(['core', 'vendor', 'community']),
|
|
78
|
+
);
|
|
79
|
+
expect(Array.isArray(body.supportedSigningMethods)).toBe(true);
|
|
80
|
+
expect(body.supportedSigningMethods).toEqual(expect.arrayContaining(['ed25519']));
|
|
81
|
+
|
|
82
|
+
// The four canonical endpoint shapes from registry-operations.md
|
|
83
|
+
// (filesystem-backed registries serve packMetadata at a file path; see endpointAliases note).
|
|
84
|
+
expect(typeof body.endpoints?.registryIndex).toBe('string');
|
|
85
|
+
expect(typeof body.endpoints?.packMetadata).toBe('string');
|
|
86
|
+
expect(typeof body.endpoints?.versionManifest).toBe('string');
|
|
87
|
+
expect(typeof body.endpoints?.versionTarball).toBe('string');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('registry-public: packs.openwop.dev index', () => {
|
|
92
|
+
it('GET /v1/index.json returns a non-empty pack list with valid name + version shapes', async () => {
|
|
93
|
+
if (!ENABLED) return;
|
|
94
|
+
|
|
95
|
+
const res = await get('/v1/index.json');
|
|
96
|
+
expect(res.status).toBe(200);
|
|
97
|
+
|
|
98
|
+
const body = res.json as {
|
|
99
|
+
packs?: Array<{ name?: string; latestVersion?: string }>;
|
|
100
|
+
generated?: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
expect(Array.isArray(body.packs)).toBe(true);
|
|
104
|
+
expect(body.packs?.length ?? 0).toBeGreaterThan(0);
|
|
105
|
+
|
|
106
|
+
for (const p of body.packs ?? []) {
|
|
107
|
+
expect(p.name, `pack name must match reverse-DNS pattern: ${p.name}`).toMatch(PACK_NAME_RE);
|
|
108
|
+
expect(p.latestVersion, `pack version must be semver: ${p.latestVersion}`).toMatch(SEMVER_RE);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('registry-public: spec-canonical pack manifests resolve', () => {
|
|
114
|
+
const KNOWN_PACKS = [
|
|
115
|
+
{ name: 'core.openwop.examples', version: '1.0.0' },
|
|
116
|
+
{ name: 'community.openwop-team.demo', version: '0.1.0' },
|
|
117
|
+
{ name: 'vendor.openwop.rust-hello', version: '1.0.0' },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
for (const { name, version } of KNOWN_PACKS) {
|
|
121
|
+
it(`GET /v1/packs/${name}/-/${version}.json returns a valid manifest`, async () => {
|
|
122
|
+
if (!ENABLED) return;
|
|
123
|
+
|
|
124
|
+
const res = await get(`/v1/packs/${name}/-/${version}.json`);
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
|
|
127
|
+
const manifest = res.json as { name?: string; version?: string };
|
|
128
|
+
expect(manifest.name).toBe(name);
|
|
129
|
+
expect(manifest.version).toBe(version);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('registry-public: tarball + signature + Ed25519 verify roundtrip', () => {
|
|
135
|
+
// core.openwop.examples@1.0.0 is the canonical reference pack for this
|
|
136
|
+
// check: published since the registry MVP, signed with the
|
|
137
|
+
// `openwop-registry-root` key over the whole tarball (method='ed25519').
|
|
138
|
+
// The same recipe applies to any pack at the registry; this scenario
|
|
139
|
+
// exercises the worst-case full roundtrip so clients have a wire-level
|
|
140
|
+
// contract for verifying packs before installing them.
|
|
141
|
+
//
|
|
142
|
+
// What gets asserted, in order:
|
|
143
|
+
// 1. Version manifest, tarball, signature, and public key all 200.
|
|
144
|
+
// 2. SRI integrity in the manifest matches a fresh sha256 of the
|
|
145
|
+
// tarball bytes — protects against tarball tampering between
|
|
146
|
+
// publish + retrieval.
|
|
147
|
+
// 3. Detached Ed25519 signature verifies against the public key over
|
|
148
|
+
// the bytes the publisher signed (per signing.method).
|
|
149
|
+
const PACK_NAME = 'core.openwop.examples';
|
|
150
|
+
const PACK_VERSION = '1.0.0';
|
|
151
|
+
|
|
152
|
+
async function getBinary(path: string): Promise<{ status: number; bytes: Buffer }> {
|
|
153
|
+
const res = await fetch(`${REGISTRY_BASE}${path}`);
|
|
154
|
+
const ab = await res.arrayBuffer();
|
|
155
|
+
return { status: res.status, bytes: Buffer.from(ab) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function getText(path: string): Promise<{ status: number; body: string }> {
|
|
159
|
+
const res = await fetch(`${REGISTRY_BASE}${path}`);
|
|
160
|
+
const body = await res.text();
|
|
161
|
+
return { status: res.status, body };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
it(`tarball + sig + public key all retrievable, SRI matches, Ed25519 verifies for ${PACK_NAME}@${PACK_VERSION}`, async () => {
|
|
165
|
+
if (!ENABLED) return;
|
|
166
|
+
|
|
167
|
+
// 1. Manifest (JSON).
|
|
168
|
+
const manifestRes = await get(`/v1/packs/${PACK_NAME}/-/${PACK_VERSION}.json`);
|
|
169
|
+
expect(manifestRes.status).toBe(200);
|
|
170
|
+
const manifest = manifestRes.json as {
|
|
171
|
+
signing?: { method?: string; keyId?: string; publicKeyUrl?: string };
|
|
172
|
+
integrity?: string;
|
|
173
|
+
};
|
|
174
|
+
expect(typeof manifest.signing?.method).toBe('string');
|
|
175
|
+
expect(typeof manifest.signing?.keyId).toBe('string');
|
|
176
|
+
expect(typeof manifest.integrity).toBe('string');
|
|
177
|
+
|
|
178
|
+
// 2. Tarball.
|
|
179
|
+
const tarball = await getBinary(`/v1/packs/${PACK_NAME}/-/${PACK_VERSION}.tgz`);
|
|
180
|
+
expect(tarball.status, 'tarball MUST be retrievable').toBe(200);
|
|
181
|
+
expect(tarball.bytes.byteLength, 'tarball MUST be non-empty').toBeGreaterThan(0);
|
|
182
|
+
|
|
183
|
+
// 3. Detached signature (64-byte raw Ed25519).
|
|
184
|
+
const sig = await getBinary(`/v1/packs/${PACK_NAME}/-/${PACK_VERSION}.sig`);
|
|
185
|
+
expect(sig.status, 'signature MUST be retrievable').toBe(200);
|
|
186
|
+
expect(sig.bytes.byteLength, 'Ed25519 detached signature MUST be 64 bytes').toBe(64);
|
|
187
|
+
|
|
188
|
+
// 4. Public key — fetch from the publisher-declared URL when present,
|
|
189
|
+
// else fall back to the canonical `/keys/<keyId>.pub` shape.
|
|
190
|
+
const keyUrl = manifest.signing!.publicKeyUrl ?? `/keys/${manifest.signing!.keyId}.pub`;
|
|
191
|
+
const keyRes = await getText(keyUrl);
|
|
192
|
+
expect(keyRes.status, `public key MUST be retrievable at ${keyUrl}`).toBe(200);
|
|
193
|
+
const publicKey = createPublicKey(keyRes.body);
|
|
194
|
+
|
|
195
|
+
// 5. SRI integrity check: `sha256-<base64>=` MUST match a fresh
|
|
196
|
+
// sha256 of the tarball bytes per registry-operations.md.
|
|
197
|
+
expect(manifest.integrity).toMatch(/^sha256-[A-Za-z0-9+/]+=*$/);
|
|
198
|
+
const expectedSri = `sha256-${createHash('sha256').update(tarball.bytes).digest('base64')}`;
|
|
199
|
+
expect(expectedSri, 'SRI integrity in manifest MUST match a fresh sha256 of the tarball').toBe(
|
|
200
|
+
manifest.integrity,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// 6. Ed25519 verification. Two canonical signing conventions per
|
|
204
|
+
// `node-packs.md` §"Signing recipe": `method=ed25519` signs the
|
|
205
|
+
// whole tarball; `method=manual` signs the pack.json bytes inside
|
|
206
|
+
// the tarball. core.openwop.examples uses `ed25519`.
|
|
207
|
+
const method = manifest.signing!.method;
|
|
208
|
+
expect(['ed25519', 'manual']).toContain(method);
|
|
209
|
+
|
|
210
|
+
// For `ed25519` the signed bytes are the tarball; for `manual` the
|
|
211
|
+
// signed bytes are pack.json extracted from the tarball. This
|
|
212
|
+
// scenario picks `core.openwop.examples` specifically because it's
|
|
213
|
+
// `method=ed25519` — the simpler path. Extending to `manual` would
|
|
214
|
+
// require the tarball extractor from registry/scripts/verify-
|
|
215
|
+
// signatures.mjs which is intentionally out of scope here.
|
|
216
|
+
if (method !== 'ed25519') return;
|
|
217
|
+
|
|
218
|
+
const verified = cryptoVerify(null, tarball.bytes, publicKey, sig.bytes);
|
|
219
|
+
expect(verified, `Ed25519 signature over ${PACK_NAME}@${PACK_VERSION}.tgz MUST verify against ${manifest.signing!.keyId}`)
|
|
220
|
+
.toBe(true);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-host LLM cache-key parity (replay.md §"LLM cache-key recipe").
|
|
3
|
+
*
|
|
4
|
+
* Verifies that two OpenWOP-compliant hosts replaying the same LLM
|
|
5
|
+
* provider request compute the same cache key. The recipe is normative
|
|
6
|
+
* (replay.md §B): canonical JSON of `(provider, model, messages, tools,
|
|
7
|
+
* temperature, topP, topK, responseFormat)` → SHA-256 → lowercase hex.
|
|
8
|
+
*
|
|
9
|
+
* Status: PLACEHOLDER. As of 2026-05-11, neither reference host
|
|
10
|
+
* (`examples/hosts/in-memory/`, `examples/hosts/sqlite/`) implements
|
|
11
|
+
* LLM-calling nodes — both execute only `core.noop` / `core.delay` /
|
|
12
|
+
* `core.approvalGate` fixtures. This scenario lands as `it.todo()` so
|
|
13
|
+
* the contract surface is tracked; assertions land when the first
|
|
14
|
+
* reference host ships an LLM-call node.
|
|
15
|
+
*
|
|
16
|
+
* What the live scenario WILL exercise (when implemented):
|
|
17
|
+
* 1. Boot host A against `OPENWOP_BASE_URL`.
|
|
18
|
+
* 2. Boot host B against `OPENWOP_BASE_URL_B`.
|
|
19
|
+
* 3. Submit the same workflow + inputs (an LLM-calling fixture).
|
|
20
|
+
* 4. Read each host's emitted `node.completed.payload.cacheKey` (or
|
|
21
|
+
* equivalent debug-bundle surface).
|
|
22
|
+
* 5. Assert the two hex strings are equal.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v1/replay.md §"LLM cache-key recipe"
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it } from 'vitest';
|
|
28
|
+
|
|
29
|
+
describe('replay-llm-cache-key: cross-host determinism (placeholder)', () => {
|
|
30
|
+
it.todo(
|
|
31
|
+
'two hosts replaying the same LLM provider request compute the same cache key (replay.md §D)',
|
|
32
|
+
);
|
|
33
|
+
it.todo('LLM cache key is computed via SHA-256 of canonical JSON per replay.md §B');
|
|
34
|
+
it.todo('cache key omits non-recipe fields (max_tokens, stop, stream, seed, etc.) per replay.md §A');
|
|
35
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay retention-expiry scenario per `spec/v1/replay.md` §"Retention
|
|
3
|
+
* and garbage collection."
|
|
4
|
+
*
|
|
5
|
+
* Verifies the normative `replay.md:246` requirement:
|
|
6
|
+
*
|
|
7
|
+
* > If the source run still exists but the event range needed for
|
|
8
|
+
* > `fromSeq` has expired, the host MUST reject the fork with
|
|
9
|
+
* > `410 Gone` or `422 Unprocessable Entity` using the canonical
|
|
10
|
+
* > error envelope. The error `details` SHOULD include `sourceRunId`,
|
|
11
|
+
* > `fromSeq`, and the retention boundary when known.
|
|
12
|
+
*
|
|
13
|
+
* Forcing expiry is environmental (hosts don't standardize a force-
|
|
14
|
+
* expire endpoint — that's the same RFC 0009 Q#1 surface area). The
|
|
15
|
+
* scenario reads two operator-supplied env vars:
|
|
16
|
+
*
|
|
17
|
+
* - `OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID` — runId of a run whose
|
|
18
|
+
* events past `fromSeq` are known-expired on the host under test.
|
|
19
|
+
* - `OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ` — the `fromSeq` to
|
|
20
|
+
* fork against (defaults to 0; pre-expired ranges typically
|
|
21
|
+
* start from the earliest event).
|
|
22
|
+
*
|
|
23
|
+
* When neither is supplied, the scenario asserts only that the
|
|
24
|
+
* `replay` capability advertisement is well-formed and that
|
|
25
|
+
* `retention` metadata (when present) types correctly. The
|
|
26
|
+
* 410/422 envelope assertion soft-skips.
|
|
27
|
+
*
|
|
28
|
+
* @see spec/v1/replay.md §"Retention and garbage collection"
|
|
29
|
+
* @see RFCS/0009-production-profile-conformance.md §C (parallel
|
|
30
|
+
* retention-expiry pattern for run snapshots)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { driver } from '../lib/driver.js';
|
|
35
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
36
|
+
|
|
37
|
+
interface ReplayRetentionCaps {
|
|
38
|
+
windowSeconds?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ReplayCaps {
|
|
42
|
+
supported?: boolean;
|
|
43
|
+
modes?: string[];
|
|
44
|
+
retention?: ReplayRetentionCaps;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PROFILE = 'openwop-replay-fork';
|
|
48
|
+
|
|
49
|
+
async function readReplayCaps(): Promise<ReplayCaps | undefined> {
|
|
50
|
+
// Per existing replay-fork.test.ts convention: `replay` lives at the
|
|
51
|
+
// top level of the discovery body, not under capabilities.*.
|
|
52
|
+
const disco = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
53
|
+
if (disco.status !== 200) return undefined;
|
|
54
|
+
return (disco.json as { replay?: ReplayCaps }).replay;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isProfileAdvertised(replay: ReplayCaps | undefined): boolean {
|
|
58
|
+
return replay?.supported === true && Array.isArray(replay.modes) && replay.modes.length > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('replay-retention-expiry: capability shape', () => {
|
|
62
|
+
it('host advertising replay surfaces well-formed retention metadata when present', async () => {
|
|
63
|
+
const replay = await readReplayCaps();
|
|
64
|
+
|
|
65
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(replay))) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
expect(replay?.supported, driver.describe(
|
|
70
|
+
'replay.md §"Retention and garbage collection"',
|
|
71
|
+
'replay.supported MUST be true when the host claims the openwop-replay-fork profile',
|
|
72
|
+
)).toBe(true);
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
Array.isArray(replay?.modes) && (replay?.modes?.length ?? 0) > 0,
|
|
76
|
+
driver.describe(
|
|
77
|
+
'profiles.md §`openwop-replay-fork`',
|
|
78
|
+
'replay.modes MUST be a non-empty array',
|
|
79
|
+
),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
|
|
82
|
+
// retention metadata is OPTIONAL per replay.md (the spec requires
|
|
83
|
+
// hosts document retention; it doesn't yet require advertising
|
|
84
|
+
// the window in discovery). When advertised, type strictly.
|
|
85
|
+
if (replay?.retention?.windowSeconds !== undefined) {
|
|
86
|
+
expect(
|
|
87
|
+
Number.isInteger(replay.retention.windowSeconds) &&
|
|
88
|
+
replay.retention.windowSeconds >= 0,
|
|
89
|
+
driver.describe(
|
|
90
|
+
'replay.md §"Retention and garbage collection"',
|
|
91
|
+
'replay.retention.windowSeconds MUST be a non-negative integer when advertised',
|
|
92
|
+
),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('replay-retention-expiry: 410/422 on expired-range fork', () => {
|
|
99
|
+
it('POST /v1/runs/{expiredRunId}:fork returns 410 or 422 with canonical envelope', async () => {
|
|
100
|
+
const replay = await readReplayCaps();
|
|
101
|
+
|
|
102
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(replay))) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const expiredRunId = process.env.OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID;
|
|
107
|
+
if (!expiredRunId) {
|
|
108
|
+
// eslint-disable-next-line no-console
|
|
109
|
+
console.warn(
|
|
110
|
+
'[replay-retention-expiry] OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID not supplied; skipping envelope assertion (operator must produce a known-expired run id and pass it via env)',
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fromSeqEnv = process.env.OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ;
|
|
116
|
+
const fromSeq = fromSeqEnv ? Number.parseInt(fromSeqEnv, 10) : 0;
|
|
117
|
+
if (!Number.isFinite(fromSeq) || fromSeq < 0) {
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.warn(
|
|
120
|
+
`[replay-retention-expiry] OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ=${String(fromSeqEnv)} is not a non-negative integer; skipping`,
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Pick a mode the host advertises. Per replay.md the envelope
|
|
126
|
+
// assertion applies regardless of mode — replay and branch both
|
|
127
|
+
// depend on the source event log past fromSeq.
|
|
128
|
+
const mode = replay?.modes?.[0] ?? 'replay';
|
|
129
|
+
|
|
130
|
+
const res = await driver.post(
|
|
131
|
+
`/v1/runs/${encodeURIComponent(expiredRunId)}:fork`,
|
|
132
|
+
{ mode, fromSeq },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(
|
|
136
|
+
res.status === 410 || res.status === 422,
|
|
137
|
+
driver.describe(
|
|
138
|
+
'replay.md §"Retention and garbage collection"',
|
|
139
|
+
'fork against expired event range MUST return 410 Gone or 422 Unprocessable Entity',
|
|
140
|
+
),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
|
|
143
|
+
const body = res.json as {
|
|
144
|
+
error?: string;
|
|
145
|
+
message?: string;
|
|
146
|
+
details?: {
|
|
147
|
+
sourceRunId?: string;
|
|
148
|
+
fromSeq?: number;
|
|
149
|
+
retentionBoundary?: string | number;
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
expect(typeof body.error, driver.describe(
|
|
154
|
+
'replay.md §"Retention and garbage collection"',
|
|
155
|
+
'expired-fork response MUST use the canonical error envelope ({error, message, details?})',
|
|
156
|
+
)).toBe('string');
|
|
157
|
+
expect((body.error ?? '').length).toBeGreaterThan(0);
|
|
158
|
+
|
|
159
|
+
expect(typeof body.message).toBe('string');
|
|
160
|
+
expect((body.message ?? '').length).toBeGreaterThan(0);
|
|
161
|
+
|
|
162
|
+
// details.{sourceRunId, fromSeq, retentionBoundary} are SHOULD —
|
|
163
|
+
// soft-check when present, MUST NOT mismatch when present.
|
|
164
|
+
if (body.details?.sourceRunId !== undefined) {
|
|
165
|
+
expect(body.details.sourceRunId, driver.describe(
|
|
166
|
+
'replay.md §"Retention and garbage collection"',
|
|
167
|
+
'details.sourceRunId (when present) MUST match the runId in the request path',
|
|
168
|
+
)).toBe(expiredRunId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (body.details?.fromSeq !== undefined) {
|
|
172
|
+
expect(body.details.fromSeq, driver.describe(
|
|
173
|
+
'replay.md §"Retention and garbage collection"',
|
|
174
|
+
'details.fromSeq (when present) MUST match the fromSeq supplied in the request body',
|
|
175
|
+
)).toBe(fromSeq);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|