@openwop/openwop-conformance 1.1.1 → 1.3.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 +90 -0
- package/README.md +2 -2
- package/api/redocly.yaml +15 -0
- package/coverage.md +27 -14
- package/fixtures/conformance-agent-low-confidence.json +7 -4
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
- package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
- package/fixtures/conformance-agent-reasoning.json +23 -4
- package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
- package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
- package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
- package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
- package/fixtures/conformance-dispatch-input-mapping.json +49 -0
- package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
- package/fixtures/conformance-dispatch-output-mapping.json +49 -0
- package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
- package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
- package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
- package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
- package/fixtures.md +18 -2
- package/package.json +1 -1
- package/schemas/README.md +7 -0
- package/schemas/agent-ref.schema.json +1 -1
- package/schemas/ai-envelope.schema.json +106 -0
- package/schemas/capabilities.schema.json +264 -0
- package/schemas/core-conformance-mock-agent-config.schema.json +152 -0
- package/schemas/dispatch-config.schema.json +26 -0
- package/schemas/envelopes/clarification.request.schema.json +43 -0
- package/schemas/envelopes/error.schema.json +26 -0
- package/schemas/envelopes/schema.request.schema.json +22 -0
- package/schemas/envelopes/schema.response.schema.json +22 -0
- package/schemas/node-pack-manifest.schema.json +5 -0
- package/schemas/pack-lockfile.schema.json +16 -0
- package/schemas/run-event-payloads.schema.json +35 -1
- package/schemas/run-event.schema.json +2 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
- package/src/lib/driver.ts +15 -0
- package/src/lib/env.ts +51 -0
- package/src/lib/event-log-query.ts +62 -0
- package/src/lib/fixtures.ts +38 -1
- package/src/lib/host-toggle.ts +54 -0
- package/src/lib/multi-agent-capabilities.ts +10 -0
- package/src/lib/otel-scrape.ts +59 -0
- package/src/lib/webhook-receiver.ts +137 -0
- package/src/lib/workflow-chain-expansion.ts +213 -0
- package/src/scenarios/agentPackCatalog.test.ts +216 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
- package/src/scenarios/agentReasoningEvents.test.ts +58 -7
- package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
- package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
- package/src/scenarios/ai-envelope-shape.test.ts +362 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +261 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +268 -0
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +284 -0
- package/src/scenarios/aiEnvelope.redaction.test.ts +253 -0
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +226 -0
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +194 -0
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +267 -0
- package/src/scenarios/append-ordering.test.ts +44 -0
- package/src/scenarios/artifact-auth.test.ts +58 -0
- package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/blob-presign-expiry.test.ts +99 -0
- package/src/scenarios/blob-roundtrip.test.ts +0 -0
- package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +73 -0
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +129 -0
- package/src/scenarios/dispatch-input-mapping.test.ts +163 -0
- package/src/scenarios/dispatch-output-mapping.test.ts +155 -0
- package/src/scenarios/fixtures-gating.test.ts +139 -1
- package/src/scenarios/fs-path-traversal.test.ts +124 -0
- package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
- package/src/scenarios/kv-atomic-increment.test.ts +74 -0
- package/src/scenarios/kv-cas.test.ts +75 -0
- package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
- package/src/scenarios/kv-ttl-expiry.test.ts +78 -0
- package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
- package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
- package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
- package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
- package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
- package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
- package/src/scenarios/pack-registry-publish.test.ts +231 -51
- package/src/scenarios/pause-resume.test.ts +43 -0
- package/src/scenarios/provider-usage.test.ts +185 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +121 -0
- package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +88 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +166 -25
- package/src/scenarios/search-bm25-roundtrip.test.ts +92 -0
- package/src/scenarios/spec-corpus-validity.test.ts +17 -1
- package/src/scenarios/sql-injection-rejection.test.ts +84 -0
- package/src/scenarios/sql-transaction-atomicity.test.ts +95 -0
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +103 -0
- package/src/scenarios/subworkflow-input-mapping.test.ts +170 -0
- package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
- package/src/scenarios/table-cursor-pagination.test.ts +85 -0
- package/src/scenarios/table-schema-enforcement.test.ts +84 -0
- package/src/scenarios/vector-knn-roundtrip.test.ts +88 -0
- package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
- package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
- package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
- package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow-chain pack manifest validation — `workflow-chain-packs.md` §Manifest format
|
|
3
|
+
* + `schemas/workflow-chain-pack-manifest.schema.json` (closes RFC 0013 Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* Server-free schema-validation scenario. Exercises the new
|
|
6
|
+
* `workflow-chain-pack-manifest.schema.json` with a positive sample and
|
|
7
|
+
* two negative samples derived from the RFC's Negative examples:
|
|
8
|
+
*
|
|
9
|
+
* 1. Positive: a valid `kind: "workflow-chain"` manifest with a single
|
|
10
|
+
* `chains[]` entry validates cleanly.
|
|
11
|
+
* 2. Negative — kind/contents mismatch: a manifest carrying BOTH
|
|
12
|
+
* `chains[]` AND `nodes[]` is rejected. Surface-level outcome at
|
|
13
|
+
* the registry HTTP API is `pack_kind_invalid` per the spec;
|
|
14
|
+
* schema-level outcome is an `additionalProperties` violation on
|
|
15
|
+
* `nodes` (the workflow-chain schema does not declare that field).
|
|
16
|
+
* 3. Negative — invalid `chainId`: a chain entry whose `chainId` does
|
|
17
|
+
* not match the reverse-DNS pattern is rejected with a `pattern`
|
|
18
|
+
* violation.
|
|
19
|
+
*
|
|
20
|
+
* Capability-gated scenarios for end-to-end expansion
|
|
21
|
+
* (`workflow-chain-expansion.test.ts`) and signature verification
|
|
22
|
+
* (`workflow-chain-pack-signature-verification.test.ts`) are deferred
|
|
23
|
+
* to Phase 2/3 per the RFC.
|
|
24
|
+
*
|
|
25
|
+
* @see spec/v1/workflow-chain-packs.md
|
|
26
|
+
* @see schemas/workflow-chain-pack-manifest.schema.json
|
|
27
|
+
* @see RFCS/0013-workflow-chain-packs.md
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
32
|
+
import { join, dirname } from 'node:path';
|
|
33
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
34
|
+
import addFormats from 'ajv-formats';
|
|
35
|
+
import type { ErrorObject } from 'ajv';
|
|
36
|
+
import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
|
|
37
|
+
|
|
38
|
+
const SCHEMA_PATH = join(SCHEMAS_DIR, 'workflow-chain-pack-manifest.schema.json');
|
|
39
|
+
// In-repo example pack — proves the schema validates a non-trivial
|
|
40
|
+
// real-world-shaped manifest (closes RFC 0013 Phase 4 in-tree path).
|
|
41
|
+
// Resolved relative to the repo root (V1_DIR is non-null in the repo
|
|
42
|
+
// layout AND in any in-tree mirror; null under the published-tarball
|
|
43
|
+
// layout where examples/ isn't bundled). Skipped cleanly when unavailable.
|
|
44
|
+
const REPO_ROOT = V1_DIR ? dirname(dirname(V1_DIR)) : null;
|
|
45
|
+
const EXAMPLE_PACK_PATH = REPO_ROOT
|
|
46
|
+
? join(REPO_ROOT, 'examples/packs/workflow-chain-sample/pack.json')
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
describe('category: workflow-chain-pack manifest validation', () => {
|
|
50
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
51
|
+
addFormats(ajv);
|
|
52
|
+
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
|
|
53
|
+
const validate = ajv.compile(schema);
|
|
54
|
+
|
|
55
|
+
it('positive: a valid workflow-chain pack manifest validates cleanly', () => {
|
|
56
|
+
const manifest = {
|
|
57
|
+
name: 'vendor.acme.editor-presets',
|
|
58
|
+
version: '1.0.0',
|
|
59
|
+
kind: 'workflow-chain',
|
|
60
|
+
description: 'Author-time editor presets.',
|
|
61
|
+
engines: { openwop: '>=1.0.0 <2.0.0' },
|
|
62
|
+
chains: [
|
|
63
|
+
{
|
|
64
|
+
chainId: 'vendor.acme.generatePRD',
|
|
65
|
+
version: '1.0.0',
|
|
66
|
+
label: 'Generate PRD',
|
|
67
|
+
description: 'Single-node AI call with PRD authoring prompt.',
|
|
68
|
+
parameters: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
required: ['productIdea'],
|
|
71
|
+
properties: {
|
|
72
|
+
productIdea: { type: 'string' },
|
|
73
|
+
targetAudience: { type: 'string', default: '' },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
dag: {
|
|
77
|
+
nodes: [
|
|
78
|
+
{
|
|
79
|
+
id: 'prd-call',
|
|
80
|
+
typeId: 'core.ai.callPrompt',
|
|
81
|
+
config: {
|
|
82
|
+
systemPrompt: 'Write a PRD for: {{params.productIdea}}',
|
|
83
|
+
envelopeType: 'prd.create',
|
|
84
|
+
provider: 'anthropic',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
edges: [],
|
|
89
|
+
},
|
|
90
|
+
outputs: {
|
|
91
|
+
prdId: { type: 'string', description: 'Created PRD artifact id.' },
|
|
92
|
+
},
|
|
93
|
+
capabilities: ['side-effectful'],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
const ok = validate(manifest);
|
|
98
|
+
const errs = (validate.errors ?? [])
|
|
99
|
+
.map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
|
|
100
|
+
.join('\n');
|
|
101
|
+
expect(
|
|
102
|
+
ok,
|
|
103
|
+
`Positive sample MUST validate against workflow-chain-pack-manifest.schema.json — got:\n${errs}`,
|
|
104
|
+
).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('negative: manifest mixing chains[] AND nodes[] is rejected (pack_kind_invalid)', () => {
|
|
108
|
+
// Per workflow-chain-packs.md §Pack kind discriminator: "Manifests MUST
|
|
109
|
+
// have exactly one of nodes[] (kind=node) OR chains[] (kind=workflow-chain).
|
|
110
|
+
// Manifests containing both MUST be rejected at manifest validation with
|
|
111
|
+
// error code pack_kind_invalid." The schema-level enforcement is via
|
|
112
|
+
// additionalProperties: false (the workflow-chain schema does not declare
|
|
113
|
+
// a `nodes` property, so its presence triggers the violation).
|
|
114
|
+
const manifest = {
|
|
115
|
+
name: 'vendor.acme.mixed',
|
|
116
|
+
version: '1.0.0',
|
|
117
|
+
kind: 'workflow-chain',
|
|
118
|
+
engines: { openwop: '>=1.0.0' },
|
|
119
|
+
nodes: [
|
|
120
|
+
{ typeId: 'vendor.acme.foo', version: '1.0.0', category: 'data', role: 'pure' },
|
|
121
|
+
],
|
|
122
|
+
chains: [
|
|
123
|
+
{
|
|
124
|
+
chainId: 'vendor.acme.bar',
|
|
125
|
+
version: '1.0.0',
|
|
126
|
+
label: 'Bar',
|
|
127
|
+
description: 'x',
|
|
128
|
+
parameters: {},
|
|
129
|
+
dag: { nodes: [{ id: 'n', typeId: 'core.identity' }], edges: [] },
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
const ok = validate(manifest);
|
|
134
|
+
expect(
|
|
135
|
+
ok,
|
|
136
|
+
'Manifest with both nodes[] and chains[] MUST fail workflow-chain schema validation (pack_kind_invalid at the registry surface).',
|
|
137
|
+
).toBe(false);
|
|
138
|
+
const hasAdditionalPropertiesErr = (validate.errors ?? []).some(
|
|
139
|
+
(e: ErrorObject) => e.keyword === 'additionalProperties',
|
|
140
|
+
);
|
|
141
|
+
expect(
|
|
142
|
+
hasAdditionalPropertiesErr,
|
|
143
|
+
'Expected an `additionalProperties` violation flagging the unexpected `nodes` field.',
|
|
144
|
+
).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('negative: chain entry with invalid chainId is rejected (pattern violation)', () => {
|
|
148
|
+
// Per workflow-chain-packs.md §Chain entry shape: chainId MUST match the
|
|
149
|
+
// reverse-DNS pattern `^[a-z][a-zA-Z0-9._-]*$`. An empty string, leading
|
|
150
|
+
// digit, or any other shape violating the pattern fails validation.
|
|
151
|
+
const manifest = {
|
|
152
|
+
name: 'vendor.acme.editor-presets',
|
|
153
|
+
version: '1.0.0',
|
|
154
|
+
kind: 'workflow-chain',
|
|
155
|
+
engines: { openwop: '>=1.0.0' },
|
|
156
|
+
chains: [
|
|
157
|
+
{
|
|
158
|
+
// INVALID — leading digit, contains uppercase that breaks the
|
|
159
|
+
// first-char rule, AND a slash that no chainId is allowed to carry.
|
|
160
|
+
chainId: '9Bad/Id',
|
|
161
|
+
version: '1.0.0',
|
|
162
|
+
label: 'Bad',
|
|
163
|
+
description: 'x',
|
|
164
|
+
parameters: {},
|
|
165
|
+
dag: { nodes: [{ id: 'n', typeId: 'core.identity' }], edges: [] },
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
const ok = validate(manifest);
|
|
170
|
+
expect(
|
|
171
|
+
ok,
|
|
172
|
+
'Manifest with invalid chainId MUST fail workflow-chain schema validation.',
|
|
173
|
+
).toBe(false);
|
|
174
|
+
const hasPatternErr = (validate.errors ?? []).some(
|
|
175
|
+
(e: ErrorObject) =>
|
|
176
|
+
e.keyword === 'pattern' && (e.instancePath ?? '').includes('chainId'),
|
|
177
|
+
);
|
|
178
|
+
expect(
|
|
179
|
+
hasPatternErr,
|
|
180
|
+
'Expected a `pattern` violation on the chains[].chainId field.',
|
|
181
|
+
).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('positive: the in-repo example pack at examples/packs/workflow-chain-sample/ validates against the schema', () => {
|
|
185
|
+
if (!EXAMPLE_PACK_PATH || !existsSync(EXAMPLE_PACK_PATH)) {
|
|
186
|
+
// Published-tarball layout doesn't ship examples/; skip cleanly.
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const manifest = JSON.parse(readFileSync(EXAMPLE_PACK_PATH, 'utf8'));
|
|
190
|
+
const ok = validate(manifest);
|
|
191
|
+
const errs = (validate.errors ?? [])
|
|
192
|
+
.map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
|
|
193
|
+
.join('\n');
|
|
194
|
+
expect(
|
|
195
|
+
ok,
|
|
196
|
+
`examples/packs/workflow-chain-sample/pack.json MUST validate against workflow-chain-pack-manifest.schema.json (closes RFC 0013 Phase 4 in-tree path). Errors:\n${errs}`,
|
|
197
|
+
).toBe(true);
|
|
198
|
+
// Spot-check the structural claims the example README makes:
|
|
199
|
+
expect(manifest.kind, 'example pack MUST declare kind: "workflow-chain"').toBe(
|
|
200
|
+
'workflow-chain',
|
|
201
|
+
);
|
|
202
|
+
expect(
|
|
203
|
+
Array.isArray(manifest.chains) && manifest.chains.length === 2,
|
|
204
|
+
'example pack MUST ship exactly 2 chains (1-node + 2-node shapes) per its README contract',
|
|
205
|
+
).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('negative: omitting kind field rejects (kind is required)', () => {
|
|
209
|
+
// Defensive — the workflow-chain schema makes `kind` REQUIRED so a
|
|
210
|
+
// node-pack-shape manifest can't accidentally validate against it.
|
|
211
|
+
const manifest = {
|
|
212
|
+
name: 'vendor.acme.editor-presets',
|
|
213
|
+
version: '1.0.0',
|
|
214
|
+
engines: { openwop: '>=1.0.0' },
|
|
215
|
+
chains: [
|
|
216
|
+
{
|
|
217
|
+
chainId: 'vendor.acme.x',
|
|
218
|
+
version: '1.0.0',
|
|
219
|
+
label: 'X',
|
|
220
|
+
description: 'x',
|
|
221
|
+
parameters: {},
|
|
222
|
+
dag: { nodes: [{ id: 'n', typeId: 'core.identity' }], edges: [] },
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
const ok = validate(manifest);
|
|
227
|
+
expect(
|
|
228
|
+
ok,
|
|
229
|
+
'Manifest without kind: "workflow-chain" MUST fail this schema (the other path is node-pack-manifest.schema.json).',
|
|
230
|
+
).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow-chain pack signature verification — `workflow-chain-packs.md`
|
|
3
|
+
* §"Expansion semantics" step 2 + `node-packs.md` §Signing (reused
|
|
4
|
+
* verification flow, identical to node packs).
|
|
5
|
+
*
|
|
6
|
+
* Server-free scenario. Asserts that the Ed25519 signature verification
|
|
7
|
+
* recipe from `node-packs.md` §Signing — "`pack.json.sig` is an Ed25519
|
|
8
|
+
* signature over `pack.json` using the key at `keys/<key-id>.pem`" —
|
|
9
|
+
* works unchanged for workflow-chain pack manifests. The spec's design
|
|
10
|
+
* intent is that workflow-chain packs reuse 100% of the node-pack
|
|
11
|
+
* signing infrastructure (same algorithm, same byte recipe, same key
|
|
12
|
+
* format); this scenario proves the reuse is real, not just claimed.
|
|
13
|
+
*
|
|
14
|
+
* What this scenario covers:
|
|
15
|
+
* - A valid (manifest + signature) pair verifies cleanly.
|
|
16
|
+
* - A tampered manifest fails verification with the same shape of
|
|
17
|
+
* failure as a tampered node pack would produce.
|
|
18
|
+
* - A wrong-key signature fails verification.
|
|
19
|
+
*
|
|
20
|
+
* Why this matters: any host implementing chain expansion MUST verify
|
|
21
|
+
* the source pack's signature before resolving + expanding (step 2 of
|
|
22
|
+
* the expansion algorithm). If chain packs needed a different signing
|
|
23
|
+
* recipe than node packs, implementers would need two verification
|
|
24
|
+
* paths — that's a footgun. The spec explicitly designs them to share.
|
|
25
|
+
*
|
|
26
|
+
* @see spec/v1/workflow-chain-packs.md §"Expansion semantics" step 2
|
|
27
|
+
* @see spec/v1/node-packs.md §Signing
|
|
28
|
+
* @see RFCS/0013-workflow-chain-packs.md
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
33
|
+
|
|
34
|
+
/** Canonical workflow-chain pack manifest used as the signing target.
|
|
35
|
+
* Mirrors the spec doc's Positive example, kept tight so the test
|
|
36
|
+
* focuses on the signing path, not the manifest shape. */
|
|
37
|
+
const MANIFEST = {
|
|
38
|
+
name: 'vendor.acme.editor-presets',
|
|
39
|
+
version: '1.0.0',
|
|
40
|
+
kind: 'workflow-chain',
|
|
41
|
+
description: 'Sample workflow-chain pack used by signature-verification conformance.',
|
|
42
|
+
engines: { openwop: '>=1.0.0 <2.0.0' },
|
|
43
|
+
chains: [
|
|
44
|
+
{
|
|
45
|
+
chainId: 'vendor.acme.generatePRD',
|
|
46
|
+
version: '1.0.0',
|
|
47
|
+
label: 'Generate PRD',
|
|
48
|
+
description: 'Single-node AI call.',
|
|
49
|
+
parameters: { type: 'object', properties: { productIdea: { type: 'string' } } },
|
|
50
|
+
dag: {
|
|
51
|
+
nodes: [{ id: 'prd-call', typeId: 'core.ai.callPrompt', config: {} }],
|
|
52
|
+
edges: [],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Canonical byte serialization of the manifest used for signing.
|
|
59
|
+
* Matches the recipe in node-packs.md §Signing: "Ed25519 signature
|
|
60
|
+
* over `pack.json`" — the on-disk JSON bytes ARE the signing payload.
|
|
61
|
+
* Production tooling would use the EXACT bytes from `pack.json` in the
|
|
62
|
+
* tarball (preserved whitespace, byte-for-byte) so the verification
|
|
63
|
+
* recipe is deterministic. */
|
|
64
|
+
function manifestBytes(): Buffer {
|
|
65
|
+
return Buffer.from(JSON.stringify(MANIFEST, null, 2));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('category: workflow-chain pack signature — Ed25519 verification reuse from node-packs', () => {
|
|
69
|
+
it('valid manifest + valid signature MUST verify (positive path)', () => {
|
|
70
|
+
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
71
|
+
const manifest = manifestBytes();
|
|
72
|
+
// Per node-packs.md §Signing: Ed25519, no separate hash step (Ed25519
|
|
73
|
+
// signs the message directly). The `algorithm` arg to crypto.sign is
|
|
74
|
+
// `null` for Ed25519 — confirmed by Node's docs.
|
|
75
|
+
const signature = sign(null, manifest, privateKey);
|
|
76
|
+
const ok = verify(null, manifest, publicKey, signature);
|
|
77
|
+
expect(
|
|
78
|
+
ok,
|
|
79
|
+
'Per workflow-chain-packs.md §"Expansion semantics" step 2: the signature recipe is IDENTICAL to node-packs (Ed25519 over pack.json bytes). A valid pair MUST verify with the canonical recipe.',
|
|
80
|
+
).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('tampered manifest MUST fail verification (sha-level tamper detection)', () => {
|
|
84
|
+
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
85
|
+
const original = manifestBytes();
|
|
86
|
+
const signature = sign(null, original, privateKey);
|
|
87
|
+
// Tamper: change a single byte in the manifest after signing. This
|
|
88
|
+
// simulates a registry-side or man-in-the-middle modification — the
|
|
89
|
+
// signing recipe MUST detect ANY byte-level change.
|
|
90
|
+
const tampered = Buffer.from(original);
|
|
91
|
+
tampered[10] = tampered[10]! ^ 0x01;
|
|
92
|
+
const ok = verify(null, tampered, publicKey, signature);
|
|
93
|
+
expect(
|
|
94
|
+
ok,
|
|
95
|
+
'Per node-packs.md §Signing (reused unchanged for chain packs): Ed25519 verification MUST detect any byte-level tamper to the signed payload.',
|
|
96
|
+
).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('wrong-key signature MUST fail verification', () => {
|
|
100
|
+
const { privateKey: legitPrivKey } = generateKeyPairSync('ed25519');
|
|
101
|
+
const { publicKey: attackerPubKey } = generateKeyPairSync('ed25519');
|
|
102
|
+
const manifest = manifestBytes();
|
|
103
|
+
const signature = sign(null, manifest, legitPrivKey);
|
|
104
|
+
// Try to verify with a DIFFERENT public key than the one paired with
|
|
105
|
+
// the signing private key. This is the supply-chain attack the
|
|
106
|
+
// signing recipe defends against (attacker substitutes their own
|
|
107
|
+
// key in the manifest's `signing.publicKeyRef`).
|
|
108
|
+
const ok = verify(null, manifest, attackerPubKey, signature);
|
|
109
|
+
expect(
|
|
110
|
+
ok,
|
|
111
|
+
'Ed25519 verification MUST fail when the public key doesn\'t match the private key that produced the signature — the spec\'s trust model depends on this property.',
|
|
112
|
+
).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('chain pack manifests carry the SAME signing block shape as node packs', () => {
|
|
116
|
+
// node-packs.md §signing declares `pack.json` MAY carry a `signing`
|
|
117
|
+
// block with `publicKeyRef` / `signatureRef` / `method`. Chain packs
|
|
118
|
+
// reuse the same shape unchanged — workflow-chain-pack-manifest.
|
|
119
|
+
// schema.json's `$defs.Signing` is byte-identical to the node-pack
|
|
120
|
+
// schema's `$defs.Signing`. This assertion documents the spec
|
|
121
|
+
// design intent so future schema evolution doesn't silently
|
|
122
|
+
// diverge.
|
|
123
|
+
const signedManifest = {
|
|
124
|
+
...MANIFEST,
|
|
125
|
+
signing: {
|
|
126
|
+
publicKeyRef: 'keys/2026-05.pem',
|
|
127
|
+
signatureRef: 'pack.json.sig',
|
|
128
|
+
method: 'manual' as const,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
expect(
|
|
132
|
+
signedManifest.signing.method,
|
|
133
|
+
'workflow-chain packs MUST accept the same signing block keys as node packs (publicKeyRef + signatureRef + method) per spec design.',
|
|
134
|
+
).toBe('manual');
|
|
135
|
+
expect(signedManifest.signing.publicKeyRef).toBe('keys/2026-05.pem');
|
|
136
|
+
expect(signedManifest.signing.signatureRef).toBe('pack.json.sig');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow-chain expansion — unresolvable typeId rejection per
|
|
3
|
+
* `workflow-chain-packs.md` §"Expansion semantics (normative)" step 3
|
|
4
|
+
* + `workflow-chain-packs.md` §"Error codes" `chain_unresolvable_typeid`.
|
|
5
|
+
*
|
|
6
|
+
* Server-free scenario. Asserts that when a chain's `dag.nodes[].typeId`
|
|
7
|
+
* references an UNPUBLISHED typeId (one the destination host's pack
|
|
8
|
+
* registry can't resolve), expansion is rejected with
|
|
9
|
+
* `chain_unresolvable_typeid` BEFORE any node ids are rewritten or
|
|
10
|
+
* placeholders substituted.
|
|
11
|
+
*
|
|
12
|
+
* The "rejection at expansion time" path is load-bearing for the
|
|
13
|
+
* "additive — no new dispatch surface" invariant: if expansion produced
|
|
14
|
+
* a workflow referencing an unknown typeId, the dispatching engine
|
|
15
|
+
* would fail at run time with `unknown_typeid` and the user would see
|
|
16
|
+
* a confusing failure far from the chain-pack tile they dragged.
|
|
17
|
+
* Rejecting at edit time keeps the failure local to the author's
|
|
18
|
+
* action.
|
|
19
|
+
*
|
|
20
|
+
* Per the spec's "Negative: chain references unpublished typeId"
|
|
21
|
+
* example: the pack's manifest validation does NOT cross-check
|
|
22
|
+
* published typeId existence (cycle issues + registry-availability
|
|
23
|
+
* concerns); only the host-editor-time expansion step verifies — by
|
|
24
|
+
* which point the destination host's pack registry has authoritative
|
|
25
|
+
* knowledge of which typeIds it can resolve.
|
|
26
|
+
*
|
|
27
|
+
* @see spec/v1/workflow-chain-packs.md §"Expansion semantics" step 3
|
|
28
|
+
* @see spec/v1/workflow-chain-packs.md §"Error codes"
|
|
29
|
+
* @see conformance/src/lib/workflow-chain-expansion.ts
|
|
30
|
+
* @see RFCS/0013-workflow-chain-packs.md
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import {
|
|
35
|
+
expandChain,
|
|
36
|
+
ChainUnresolvableTypeIdError,
|
|
37
|
+
type WorkflowChain,
|
|
38
|
+
} from '../lib/workflow-chain-expansion.js';
|
|
39
|
+
|
|
40
|
+
const CHAIN_WITH_UNKNOWN_TYPEID: WorkflowChain = {
|
|
41
|
+
chainId: 'vendor.acme.someChain',
|
|
42
|
+
version: '1.0.0',
|
|
43
|
+
label: 'Some Chain',
|
|
44
|
+
description: 'References an unpublished typeId — should fail expansion.',
|
|
45
|
+
parameters: {},
|
|
46
|
+
dag: {
|
|
47
|
+
nodes: [
|
|
48
|
+
{ id: 'n1', typeId: 'made.up.foo', config: {} },
|
|
49
|
+
],
|
|
50
|
+
edges: [],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Resolver that only accepts a hardcoded set of "known" typeIds — the
|
|
55
|
+
* set the destination host's pack registry can satisfy. Mimics the
|
|
56
|
+
* realistic host-editor-time check. */
|
|
57
|
+
const KNOWN_TYPEIDS = new Set(['core.identity', 'core.ai.callPrompt']);
|
|
58
|
+
const isKnown = (typeId: string): boolean => KNOWN_TYPEIDS.has(typeId);
|
|
59
|
+
|
|
60
|
+
describe('category: workflow-chain expansion — unresolvable typeId rejection', () => {
|
|
61
|
+
it('throws ChainUnresolvableTypeIdError when chain references an unknown typeId', () => {
|
|
62
|
+
expect(
|
|
63
|
+
() =>
|
|
64
|
+
expandChain(CHAIN_WITH_UNKNOWN_TYPEID, {
|
|
65
|
+
expansionId: 'x',
|
|
66
|
+
params: {},
|
|
67
|
+
isTypeIdResolvable: isKnown,
|
|
68
|
+
}),
|
|
69
|
+
'Per spec §"Expansion semantics" step 3: hosts MUST reject expansion with `chain_unresolvable_typeid` when any fragment node\'s typeId can\'t be resolved by the destination host.',
|
|
70
|
+
).toThrow(ChainUnresolvableTypeIdError);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('error carries the offending typeId AND the chainId in `details`', () => {
|
|
74
|
+
try {
|
|
75
|
+
expandChain(CHAIN_WITH_UNKNOWN_TYPEID, {
|
|
76
|
+
expansionId: 'x',
|
|
77
|
+
params: {},
|
|
78
|
+
isTypeIdResolvable: isKnown,
|
|
79
|
+
});
|
|
80
|
+
expect.fail('expandChain MUST have thrown ChainUnresolvableTypeIdError');
|
|
81
|
+
} catch (err) {
|
|
82
|
+
expect(err, 'expected ChainUnresolvableTypeIdError').toBeInstanceOf(
|
|
83
|
+
ChainUnresolvableTypeIdError,
|
|
84
|
+
);
|
|
85
|
+
const e = err as ChainUnresolvableTypeIdError;
|
|
86
|
+
expect(
|
|
87
|
+
e.code,
|
|
88
|
+
'Per spec §"Error codes": the wire-level error code MUST be `chain_unresolvable_typeid`.',
|
|
89
|
+
).toBe('chain_unresolvable_typeid');
|
|
90
|
+
expect(
|
|
91
|
+
e.typeId,
|
|
92
|
+
'The error MUST surface the offending typeId so the host editor can render an actionable diagnostic.',
|
|
93
|
+
).toBe('made.up.foo');
|
|
94
|
+
expect(
|
|
95
|
+
e.chainId,
|
|
96
|
+
'The error MUST surface the chainId so the host editor knows which chain-pack tile triggered the rejection.',
|
|
97
|
+
).toBe('vendor.acme.someChain');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejection happens BEFORE any id rewriting or placeholder substitution', () => {
|
|
102
|
+
// If the spec allowed expansion to proceed and produce a half-built
|
|
103
|
+
// fragment with an unknown typeId, downstream tooling would see a
|
|
104
|
+
// workflow with an undispatchable node. The contract is "reject
|
|
105
|
+
// cleanly at the resolution step, no partial output."
|
|
106
|
+
let threw = false;
|
|
107
|
+
try {
|
|
108
|
+
expandChain(CHAIN_WITH_UNKNOWN_TYPEID, {
|
|
109
|
+
expansionId: 'x',
|
|
110
|
+
params: { foo: 'bar' },
|
|
111
|
+
isTypeIdResolvable: isKnown,
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
threw = true;
|
|
115
|
+
}
|
|
116
|
+
expect(
|
|
117
|
+
threw,
|
|
118
|
+
'Expansion MUST throw before producing any output — host editors rely on the "no partial expansion" guarantee to keep parent workflows clean on rejection.',
|
|
119
|
+
).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('accepts the chain when every typeId IS resolvable', () => {
|
|
123
|
+
const resolvable: WorkflowChain = {
|
|
124
|
+
...CHAIN_WITH_UNKNOWN_TYPEID,
|
|
125
|
+
dag: {
|
|
126
|
+
nodes: [{ id: 'n1', typeId: 'core.identity', config: {} }],
|
|
127
|
+
edges: [],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
expect(
|
|
131
|
+
() =>
|
|
132
|
+
expandChain(resolvable, {
|
|
133
|
+
expansionId: 'x',
|
|
134
|
+
params: {},
|
|
135
|
+
isTypeIdResolvable: isKnown,
|
|
136
|
+
}),
|
|
137
|
+
'Sanity: when every typeId resolves, expansion MUST proceed without throwing.',
|
|
138
|
+
).not.toThrow();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('throws on the FIRST unknown typeId encountered (fail-fast)', () => {
|
|
142
|
+
// Chain with two nodes — one resolvable, one not. The unresolvable one
|
|
143
|
+
// is second. The throw MUST identify the second one (the actual
|
|
144
|
+
// offender), not silently skip it.
|
|
145
|
+
const mixed: WorkflowChain = {
|
|
146
|
+
...CHAIN_WITH_UNKNOWN_TYPEID,
|
|
147
|
+
dag: {
|
|
148
|
+
nodes: [
|
|
149
|
+
{ id: 'n1', typeId: 'core.identity', config: {} },
|
|
150
|
+
{ id: 'n2', typeId: 'made.up.foo', config: {} },
|
|
151
|
+
],
|
|
152
|
+
edges: [],
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
try {
|
|
156
|
+
expandChain(mixed, {
|
|
157
|
+
expansionId: 'x',
|
|
158
|
+
params: {},
|
|
159
|
+
isTypeIdResolvable: isKnown,
|
|
160
|
+
});
|
|
161
|
+
expect.fail('MUST have thrown on n2\'s unknown typeId');
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const e = err as ChainUnresolvableTypeIdError;
|
|
164
|
+
expect(
|
|
165
|
+
e.typeId,
|
|
166
|
+
'When multiple nodes have unknown typeIds, the throw MUST identify the first unknown one encountered in declaration order.',
|
|
167
|
+
).toBe('made.up.foo');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|