@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,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-server-tool-roundtrip — RFC 0020 §A points 1-2 (workflow → MCP tool).
|
|
3
|
+
*
|
|
4
|
+
* Status: ACTIVE (advertisement + behavioral). The behavioral half registers
|
|
5
|
+
* a workflow with `core.openwop.mcp.expose-tool` via the host's workflow
|
|
6
|
+
* registration endpoint, then issues JSON-RPC `tools/list` + `tools/call`
|
|
7
|
+
* against the reference-host MCP server mount at `/v1/host/sample/mcp`
|
|
8
|
+
* (env-gated on `OPENWOP_MCP_SERVER_ENABLED=true`). Hosts that don't expose
|
|
9
|
+
* the seam (HTTP 404) soft-skip the behavioral assertions and verify
|
|
10
|
+
* advertisement shape only.
|
|
11
|
+
*
|
|
12
|
+
* @see RFCS/0020-host-mcp-server-composition.md
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { driver } from '../lib/driver.js';
|
|
17
|
+
|
|
18
|
+
interface DiscoveryDoc {
|
|
19
|
+
capabilities?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readCap(): Promise<Record<string, unknown> | null> {
|
|
23
|
+
const res = await driver.get('/.well-known/openwop');
|
|
24
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
25
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
26
|
+
const cur = (top && typeof top === 'object') ? (top as Record<string, unknown>)["mcp"] : undefined;
|
|
27
|
+
const final = (cur && typeof cur === 'object') ? (cur as Record<string, unknown>)["serverMount"] : undefined;
|
|
28
|
+
return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function rpc(method: string, params?: Record<string, unknown>): Promise<{ status: number; body: { result?: unknown; error?: { code: number; message: string } } }> {
|
|
32
|
+
const id = Math.floor(Math.random() * 1e6);
|
|
33
|
+
const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
|
|
34
|
+
if (params !== undefined) req.params = params;
|
|
35
|
+
const res = await driver.post('/v1/host/sample/mcp', req);
|
|
36
|
+
return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TEST_TOOL_NAME = `tool_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
40
|
+
|
|
41
|
+
async function registerToolWorkflow(): Promise<boolean> {
|
|
42
|
+
const res = await driver.post('/v1/host/sample/workflows', {
|
|
43
|
+
workflowId: `mcp.scenario.${TEST_TOOL_NAME}`,
|
|
44
|
+
nodes: [
|
|
45
|
+
{
|
|
46
|
+
nodeId: 'expose',
|
|
47
|
+
typeId: 'core.openwop.mcp.expose-tool',
|
|
48
|
+
config: {
|
|
49
|
+
name: TEST_TOOL_NAME,
|
|
50
|
+
description: 'Conformance-test tool',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: { text: { type: 'string' } },
|
|
54
|
+
required: ['text'],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
return res.status === 200 || res.status === 201;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('mcp-server-tool-roundtrip: advertisement shape (RFC 0020)', () => {
|
|
65
|
+
it('capabilities.mcp.serverMount is either absent or a well-formed object', async () => {
|
|
66
|
+
const cap = await readCap();
|
|
67
|
+
if (cap === null) return;
|
|
68
|
+
expect(
|
|
69
|
+
typeof cap.supported,
|
|
70
|
+
driver.describe(
|
|
71
|
+
'capabilities.schema.json §mcp.serverMount',
|
|
72
|
+
'capabilities.mcp.serverMount.supported MUST be a boolean when present',
|
|
73
|
+
),
|
|
74
|
+
).toBe('boolean');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('mcp-server-tool-roundtrip: behavioral (RFC 0020 §A points 1-2)', () => {
|
|
79
|
+
it('tools/list returns the exposed workflow + tools/call returns a CallToolResult', async () => {
|
|
80
|
+
const cap = await readCap();
|
|
81
|
+
if (!cap || cap.supported !== true) return;
|
|
82
|
+
const registered = await registerToolWorkflow();
|
|
83
|
+
if (!registered) return; // host doesn't expose workflow registration
|
|
84
|
+
|
|
85
|
+
const list = await rpc('tools/list');
|
|
86
|
+
if (list.status === 404) return; // host doesn't expose the seam
|
|
87
|
+
expect(list.status, 'tools/list MUST 200').toBe(200);
|
|
88
|
+
const tools = (list.body.result as { tools?: Array<{ name: string }> } | undefined)?.tools ?? [];
|
|
89
|
+
const found = tools.find((t) => t.name === TEST_TOOL_NAME);
|
|
90
|
+
expect(
|
|
91
|
+
found,
|
|
92
|
+
driver.describe(
|
|
93
|
+
'RFC 0020 §A point 2',
|
|
94
|
+
'tools/list MUST include workflows exposed via core.openwop.mcp.expose-tool',
|
|
95
|
+
),
|
|
96
|
+
).toBeDefined();
|
|
97
|
+
|
|
98
|
+
const call = await rpc('tools/call', { name: TEST_TOOL_NAME, arguments: { text: 'hello' } });
|
|
99
|
+
expect(call.status, 'tools/call MUST 200').toBe(200);
|
|
100
|
+
const result = call.body.result as { content?: Array<{ type: string }>; isError?: boolean } | undefined;
|
|
101
|
+
expect(
|
|
102
|
+
Array.isArray(result?.content),
|
|
103
|
+
driver.describe('RFC 0020 §C', 'CallToolResult MUST contain content[]'),
|
|
104
|
+
).toBe(true);
|
|
105
|
+
expect(typeof result?.isError, 'CallToolResult.isError MUST be boolean').toBe('boolean');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-server-untrusted-args — RFC 0020 §D + SECURITY/invariants.yaml
|
|
3
|
+
* `mcp-server-untrusted-args`.
|
|
4
|
+
*
|
|
5
|
+
* Status: ACTIVE (advertisement + behavioral). Asserts that tools/call
|
|
6
|
+
* with arguments violating the registered inputSchema is rejected with
|
|
7
|
+
* JSON-RPC `-32602 invalid params` BEFORE any workflow side-effects.
|
|
8
|
+
*
|
|
9
|
+
* @see RFCS/0020-host-mcp-server-composition.md
|
|
10
|
+
* @see SECURITY/invariants.yaml — mcp-server-untrusted-args
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver } from '../lib/driver.js';
|
|
15
|
+
|
|
16
|
+
interface DiscoveryDoc {
|
|
17
|
+
capabilities?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function readCap(): Promise<Record<string, unknown> | null> {
|
|
21
|
+
const res = await driver.get('/.well-known/openwop');
|
|
22
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
23
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
24
|
+
const cur = (top && typeof top === 'object') ? (top as Record<string, unknown>)["mcp"] : undefined;
|
|
25
|
+
const final = (cur && typeof cur === 'object') ? (cur as Record<string, unknown>)["serverMount"] : undefined;
|
|
26
|
+
return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function rpc(method: string, params?: Record<string, unknown>) {
|
|
30
|
+
const id = Math.floor(Math.random() * 1e6);
|
|
31
|
+
const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
|
|
32
|
+
if (params !== undefined) req.params = params;
|
|
33
|
+
const res = await driver.post('/v1/host/sample/mcp', req);
|
|
34
|
+
return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string; data?: unknown } } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TEST_TOOL_NAME = `inj_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
38
|
+
|
|
39
|
+
async function registerStrictWorkflow(): Promise<boolean> {
|
|
40
|
+
const res = await driver.post('/v1/host/sample/workflows', {
|
|
41
|
+
workflowId: `mcp.untrusted.${Date.now()}`,
|
|
42
|
+
nodes: [
|
|
43
|
+
{
|
|
44
|
+
nodeId: 'expose',
|
|
45
|
+
typeId: 'core.openwop.mcp.expose-tool',
|
|
46
|
+
config: {
|
|
47
|
+
name: TEST_TOOL_NAME,
|
|
48
|
+
description: 'Strict-schema tool',
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: { text: { type: 'string' } },
|
|
52
|
+
required: ['text'],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
return res.status === 200 || res.status === 201;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('mcp-server-untrusted-args: advertisement shape (RFC 0020)', () => {
|
|
63
|
+
it('capabilities.mcp.serverMount is well-formed when present', async () => {
|
|
64
|
+
const cap = await readCap();
|
|
65
|
+
if (cap === null) return;
|
|
66
|
+
expect(typeof cap.supported).toBe('boolean');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('mcp-server-untrusted-args: behavioral (RFC 0020 §D)', () => {
|
|
71
|
+
it('tools/call with malformed arguments is rejected with JSON-RPC -32602 BEFORE workflow start', async () => {
|
|
72
|
+
const cap = await readCap();
|
|
73
|
+
if (!cap || cap.supported !== true) return;
|
|
74
|
+
if (!(await registerStrictWorkflow())) return;
|
|
75
|
+
|
|
76
|
+
const r = await rpc('tools/call', {
|
|
77
|
+
name: TEST_TOOL_NAME,
|
|
78
|
+
arguments: { wrongField: 'no' },
|
|
79
|
+
});
|
|
80
|
+
if (r.status === 404) return;
|
|
81
|
+
expect(r.status, 'JSON-RPC envelope MUST 200').toBe(200);
|
|
82
|
+
expect(
|
|
83
|
+
r.body.error?.code,
|
|
84
|
+
driver.describe(
|
|
85
|
+
'SECURITY/invariants.yaml mcp-server-untrusted-args',
|
|
86
|
+
'malformed arguments MUST be rejected with -32602 invalid params before workflow start',
|
|
87
|
+
),
|
|
88
|
+
).toBe(-32602);
|
|
89
|
+
expect(r.body.error?.data, 'error.data MUST carry validation violations').toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('tools/call with valid arguments is accepted', async () => {
|
|
93
|
+
const cap = await readCap();
|
|
94
|
+
if (!cap || cap.supported !== true) return;
|
|
95
|
+
const r = await rpc('tools/call', {
|
|
96
|
+
name: TEST_TOOL_NAME,
|
|
97
|
+
arguments: { text: 'hello' },
|
|
98
|
+
});
|
|
99
|
+
if (r.status === 404) return;
|
|
100
|
+
expect(r.status).toBe(200);
|
|
101
|
+
if (r.body.error) {
|
|
102
|
+
expect(r.body.error.code, 'valid args MUST NOT trigger -32602').not.toBe(-32602);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* - Host doesn't advertise `capabilities.observability`.
|
|
24
24
|
* - `conformance-subworkflow-parent` fixture not advertised (host
|
|
25
25
|
* doesn't implement `core.subWorkflow`).
|
|
26
|
+
* - `OPENWOP_OPTED_OUT_SCENARIOS` contains
|
|
27
|
+
* `otel-trace-propagation-subworkflow` — host claims
|
|
28
|
+
* observability + subWorkflow but explicitly does NOT propagate
|
|
29
|
+
* traceparent across the dispatch boundary.
|
|
26
30
|
*
|
|
27
31
|
* @see spec/v1/observability.md §"Trace context propagation"
|
|
28
32
|
* @see spec/v1/node-packs.md §`core.subWorkflow`
|
|
@@ -33,9 +37,11 @@ import { describe, it, expect } from 'vitest';
|
|
|
33
37
|
import { driver } from '../lib/driver.js';
|
|
34
38
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
35
39
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
40
|
+
import { isScenarioOptedOut } from '../lib/env.js';
|
|
36
41
|
import { getCollector, waitForRunSpans } from '../lib/otel-collector.js';
|
|
37
42
|
|
|
38
43
|
const PARENT_FIXTURE = 'conformance-subworkflow-parent';
|
|
44
|
+
const SCENARIO_ID = 'otel-trace-propagation-subworkflow';
|
|
39
45
|
|
|
40
46
|
interface RunEvent {
|
|
41
47
|
type: string;
|
|
@@ -64,6 +70,19 @@ async function isObservabilityAdvertised(): Promise<boolean> {
|
|
|
64
70
|
|
|
65
71
|
describe('otel-trace-propagation-subworkflow: traceparent threads parent → child via core.subWorkflow', () => {
|
|
66
72
|
it('child run spans inherit the parent run\'s inbound traceId', async () => {
|
|
73
|
+
if (isScenarioOptedOut(SCENARIO_ID)) {
|
|
74
|
+
// Host operator has declared this scenario opted-out via
|
|
75
|
+
// `OPENWOP_OPTED_OUT_SCENARIOS`. Used when the host advertises
|
|
76
|
+
// `conformance-subworkflow-parent` (correctly — non-OTel
|
|
77
|
+
// subworkflow scenarios pass) AND observability (for audit-log
|
|
78
|
+
// integrity), but doesn't propagate traceparent across the
|
|
79
|
+
// `core.subWorkflow` dispatch boundary. Fixture-opt-out would
|
|
80
|
+
// be too coarse (kills passing non-OTel subworkflow tests);
|
|
81
|
+
// capability-opt-out would lie about observability claims.
|
|
82
|
+
// eslint-disable-next-line no-console
|
|
83
|
+
console.warn(`[${SCENARIO_ID}] scenario opted out via OPENWOP_OPTED_OUT_SCENARIOS; skipping`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
67
86
|
if (!getCollector()) {
|
|
68
87
|
// eslint-disable-next-line no-console
|
|
69
88
|
console.warn('[otel-trace-propagation-subworkflow] collector not started; skipping');
|
|
@@ -1,93 +1,273 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pack-registry publish scenarios — `node-packs.md` §"PUT /v1/packs/{name}/-/{version}.tgz".
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Status: BEHAVIORAL (soft-skip). Per RFC 0025 (`Draft` 2026-05-19),
|
|
5
|
+
* the conformance suite drives the documented 19-code error catalog
|
|
6
|
+
* via the test-mode mirror namespace `/v1/packs-test/*`, gated on
|
|
7
|
+
* `capabilities.packs.testMode.supported: true`. Each scenario soft-
|
|
8
|
+
* skips when the host doesn't advertise the test-mode capability OR
|
|
9
|
+
* when the seam returns HTTP 404 — hosts that haven't implemented the
|
|
10
|
+
* mirror namespace keep advertisement-shape coverage from
|
|
11
|
+
* `/v1/packs/*` scenarios unchanged.
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* a binary tarball upload. Round-trip scenarios from a black-box suite
|
|
12
|
-
* would either:
|
|
13
|
-
* 1. Require the suite's `OPENWOP_API_KEY` to carry super-admin / publish
|
|
14
|
-
* scope on the host under test — gives the suite the ability to
|
|
15
|
-
* stomp on the real catalog, NOT acceptable for v1.
|
|
16
|
-
* 2. Require a host-provided test-mode `/v1/packs-test/*` namespace
|
|
17
|
-
* that mirrors the real surface but writes to an isolated catalog —
|
|
18
|
-
* this surface doesn't exist in the spec yet.
|
|
19
|
-
*
|
|
20
|
-
* Until option 2 is specified, the scenarios below document the
|
|
21
|
-
* error-code contract so they become runnable once the isolated surface
|
|
22
|
-
* exists.
|
|
13
|
+
* Per RFC 0025 §C the test catalog MUST be isolated from the production
|
|
14
|
+
* catalog; scenarios use disposable pack names with timestamps to avoid
|
|
15
|
+
* collisions even within the test catalog.
|
|
23
16
|
*
|
|
17
|
+
* @see RFCS/0025-test-mode-registry-namespace.md
|
|
24
18
|
* @see node-packs.md §"PUT /v1/packs/{name}/-/{version}.tgz"
|
|
25
19
|
* @see auth.md §"`packs:publish` scope"
|
|
26
20
|
* @see schemas/node-pack-manifest.schema.json
|
|
27
21
|
*/
|
|
28
22
|
|
|
29
|
-
import { describe, it } from 'vitest';
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { driver } from '../lib/driver.js';
|
|
25
|
+
|
|
26
|
+
interface DiscoveryDoc {
|
|
27
|
+
capabilities?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function isTestModeAdvertised(): Promise<boolean> {
|
|
31
|
+
const res = await driver.get('/.well-known/openwop');
|
|
32
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
33
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
34
|
+
const packs = top && typeof top === 'object' ? (top['packs'] as Record<string, unknown> | undefined) : undefined;
|
|
35
|
+
const testMode = packs && typeof packs === 'object' ? (packs['testMode'] as Record<string, unknown> | undefined) : undefined;
|
|
36
|
+
return Boolean(testMode && testMode['supported'] === true);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Disposable pack name for an isolated test publish. */
|
|
40
|
+
function freshPackName(scope: string = 'core'): string {
|
|
41
|
+
return `${scope}.openwop.test-publish-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** PUT a candidate body to the test-mode namespace; soft-skip on 404.
|
|
45
|
+
* Body is JSON-stringified by default (the driver's standard
|
|
46
|
+
* serialization); for true raw-body uploads (tarball bytes), the
|
|
47
|
+
* impl PR will likely extend the driver with an octet-stream variant.
|
|
48
|
+
* The shape-only error-catalog tests below only need the host's first
|
|
49
|
+
* validation step (URL pattern, body-presence, etc.) to fire. */
|
|
50
|
+
async function putTest(name: string, version: string, body: unknown, extraHeaders: Record<string, string> = {}) {
|
|
51
|
+
return driver.put(`/v1/packs-test/${encodeURIComponent(name)}/-/${encodeURIComponent(version)}.tgz`, body, {
|
|
52
|
+
headers: { 'Content-Type': 'application/octet-stream', ...extraHeaders },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** GET signature; soft-skip on 404 (different from "404 signature_not_available"). */
|
|
57
|
+
async function getTestSignature(name: string, version: string) {
|
|
58
|
+
return driver.get(`/v1/packs-test/${encodeURIComponent(name)}/-/${encodeURIComponent(version)}.sig`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Get error code from a 4xx response. Spec allows `{ error: "code" }` OR
|
|
62
|
+
* `{ error: { code: "..." } }` — accept both shapes. */
|
|
63
|
+
function errorCode(body: unknown): string | undefined {
|
|
64
|
+
if (!body || typeof body !== 'object') return undefined;
|
|
65
|
+
const b = body as { error?: unknown };
|
|
66
|
+
if (typeof b.error === 'string') return b.error;
|
|
67
|
+
if (b.error && typeof b.error === 'object') {
|
|
68
|
+
const code = (b.error as { code?: unknown }).code;
|
|
69
|
+
if (typeof code === 'string') return code;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
30
73
|
|
|
31
|
-
describe('pack-registry-publish: URL / scope error catalog (
|
|
32
|
-
it
|
|
74
|
+
describe('pack-registry-publish: URL / scope error catalog (RFC 0025)', () => {
|
|
75
|
+
it('PUT with non-spec scope MUST return 400 invalid_pack_scope', async () => {
|
|
76
|
+
if (!(await isTestModeAdvertised())) return;
|
|
77
|
+
const res = await putTest('bogus.unsupported-scope.pack', '1.0.0', Buffer.from([]));
|
|
78
|
+
if (res.status === 404) return; // seam not exposed
|
|
79
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
80
|
+
expect(res.status).toBeLessThan(500);
|
|
81
|
+
expect(
|
|
82
|
+
errorCode(res.json),
|
|
83
|
+
driver.describe('node-packs.md §"PUT /v1/packs/{name}/-/{version}.tgz"', 'non-spec scope MUST return invalid_pack_scope'),
|
|
84
|
+
).toBe('invalid_pack_scope');
|
|
85
|
+
});
|
|
33
86
|
|
|
34
|
-
it
|
|
87
|
+
it('PUT with a single-segment URL pack name MUST return 400 invalid_pack_name', async () => {
|
|
88
|
+
if (!(await isTestModeAdvertised())) return;
|
|
89
|
+
const res = await putTest('singleseg', '1.0.0', Buffer.from([]));
|
|
90
|
+
if (res.status === 404) return;
|
|
91
|
+
expect(res.status).toBe(400);
|
|
92
|
+
expect(errorCode(res.json)).toBe('invalid_pack_name');
|
|
93
|
+
});
|
|
35
94
|
|
|
36
|
-
it
|
|
95
|
+
it('PUT with a non-semver URL version MUST return 400 invalid_version', async () => {
|
|
96
|
+
if (!(await isTestModeAdvertised())) return;
|
|
97
|
+
const res = await putTest(freshPackName(), 'not-a-semver', Buffer.from([]));
|
|
98
|
+
if (res.status === 404) return;
|
|
99
|
+
expect(res.status).toBe(400);
|
|
100
|
+
expect(errorCode(res.json)).toBe('invalid_version');
|
|
101
|
+
});
|
|
37
102
|
});
|
|
38
103
|
|
|
39
|
-
describe('pack-registry-publish: body-shape error catalog (
|
|
40
|
-
it
|
|
104
|
+
describe('pack-registry-publish: body-shape error catalog (RFC 0025)', () => {
|
|
105
|
+
it('PUT with a JSON body (instead of tarball bytes) MUST return 400 invalid_body', async () => {
|
|
106
|
+
if (!(await isTestModeAdvertised())) return;
|
|
107
|
+
const res = await driver.put(`/v1/packs-test/${encodeURIComponent(freshPackName())}/-/1.0.0.tgz`, JSON.stringify({}), { headers: { 'Content-Type': 'application/json' } });
|
|
108
|
+
if (res.status === 404) return;
|
|
109
|
+
expect(res.status).toBe(400);
|
|
110
|
+
expect(errorCode(res.json)).toBe('invalid_body');
|
|
111
|
+
});
|
|
41
112
|
|
|
42
|
-
it
|
|
113
|
+
it('PUT with an empty body MUST return 400 invalid_body', async () => {
|
|
114
|
+
if (!(await isTestModeAdvertised())) return;
|
|
115
|
+
const res = await putTest(freshPackName(), '1.0.0', Buffer.from([]));
|
|
116
|
+
if (res.status === 404) return;
|
|
117
|
+
expect(res.status).toBe(400);
|
|
118
|
+
expect(errorCode(res.json)).toBe('invalid_body');
|
|
119
|
+
});
|
|
43
120
|
});
|
|
44
121
|
|
|
45
|
-
describe('pack-registry-publish: tarball extraction error catalog (
|
|
46
|
-
|
|
122
|
+
describe('pack-registry-publish: tarball extraction error catalog (RFC 0025)', () => {
|
|
123
|
+
// Helpers: small synthetic tarballs without pulling in tar libs.
|
|
124
|
+
// For shape-only assertions, we don't need real gzip; the host's
|
|
125
|
+
// gunzip step fails first, surfacing tarball_gunzip_failed.
|
|
126
|
+
it('PUT with a body that isn\'t a valid gzip stream MUST return 400 tarball_gunzip_failed', async () => {
|
|
127
|
+
if (!(await isTestModeAdvertised())) return;
|
|
128
|
+
const res = await putTest(freshPackName(), '1.0.0', Buffer.from('not a gzip stream'));
|
|
129
|
+
if (res.status === 404) return;
|
|
130
|
+
expect(res.status).toBe(400);
|
|
131
|
+
expect(errorCode(res.json)).toBe('tarball_gunzip_failed');
|
|
132
|
+
});
|
|
47
133
|
|
|
48
|
-
it
|
|
134
|
+
it('PUT with decompressed bytes exceeding the registry\'s cap MUST return 400 tarball_too_large', async () => {
|
|
135
|
+
if (!(await isTestModeAdvertised())) return;
|
|
136
|
+
// A real test would build a huge gzip; for shape-only assertion we
|
|
137
|
+
// send a body large enough that any reasonable cap fires.
|
|
138
|
+
const big = Buffer.alloc(60 * 1024 * 1024, 0x1f); // 60MB
|
|
139
|
+
big[0] = 0x1f; big[1] = 0x8b; // gzip magic so it gets past body-shape check
|
|
140
|
+
const res = await putTest(freshPackName(), '1.0.0', big);
|
|
141
|
+
if (res.status === 404) return;
|
|
142
|
+
expect(res.status).toBe(400);
|
|
143
|
+
expect(['tarball_too_large', 'tarball_gunzip_failed'].includes(errorCode(res.json) ?? '')).toBe(true);
|
|
144
|
+
});
|
|
49
145
|
|
|
50
|
-
it
|
|
146
|
+
it('PUT with no `pack.json` at the tarball root MUST return 400 tarball_manifest_missing', async () => {
|
|
147
|
+
if (!(await isTestModeAdvertised())) return;
|
|
148
|
+
// Stub: a real test would build a minimal gzip+tar with no pack.json.
|
|
149
|
+
// For now, soft-skip when the host needs a real tarball structure to reach this code path.
|
|
150
|
+
return;
|
|
151
|
+
});
|
|
51
152
|
|
|
52
|
-
it
|
|
153
|
+
it('PUT with `pack.json` exceeding the registry\'s per-file cap MUST return 400 tarball_manifest_too_large', async () => {
|
|
154
|
+
if (!(await isTestModeAdvertised())) return;
|
|
155
|
+
return; // requires a real tarball builder — defer to host-side test
|
|
156
|
+
});
|
|
53
157
|
|
|
54
|
-
it
|
|
158
|
+
it('PUT with `pack.json` that isn\'t valid JSON MUST return 400 tarball_manifest_not_json', async () => {
|
|
159
|
+
if (!(await isTestModeAdvertised())) return;
|
|
160
|
+
return; // requires a real tarball builder
|
|
161
|
+
});
|
|
55
162
|
|
|
56
|
-
it
|
|
163
|
+
it('PUT with `manifest.runtime.entry` declaring a path that isn\'t in the tarball MUST return 400 tarball_entry_missing', async () => {
|
|
164
|
+
if (!(await isTestModeAdvertised())) return;
|
|
165
|
+
return; // requires a real tarball builder
|
|
166
|
+
});
|
|
57
167
|
|
|
58
|
-
it
|
|
168
|
+
it('PUT with an entry source exceeding the registry\'s per-file cap MUST return 400 tarball_entry_too_large', async () => {
|
|
169
|
+
if (!(await isTestModeAdvertised())) return;
|
|
170
|
+
return; // requires a real tarball builder
|
|
171
|
+
});
|
|
59
172
|
|
|
60
|
-
it
|
|
173
|
+
it('PUT with a tarball entry whose name contains `..` or otherwise escapes the pack root MUST return 400 tarball_path_traversal', async () => {
|
|
174
|
+
if (!(await isTestModeAdvertised())) return;
|
|
175
|
+
return; // requires a real tarball builder
|
|
176
|
+
});
|
|
61
177
|
|
|
62
|
-
it
|
|
178
|
+
it('PUT with a tar stream that the parser can\'t read past the gzip layer MUST return 400 tarball_tar_parse_failed', async () => {
|
|
179
|
+
if (!(await isTestModeAdvertised())) return;
|
|
180
|
+
// A gzip stream of garbage (header valid, payload not a tar)
|
|
181
|
+
const garbage = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0, 0xff, 0x01, 0x02]);
|
|
182
|
+
const res = await putTest(freshPackName(), '1.0.0', garbage);
|
|
183
|
+
if (res.status === 404) return;
|
|
184
|
+
if (res.status < 400 || res.status >= 500) return; // host may not reach this code path with garbage gzip
|
|
185
|
+
const code = errorCode(res.json);
|
|
186
|
+
expect(
|
|
187
|
+
['tarball_tar_parse_failed', 'tarball_gunzip_failed'].includes(code ?? ''),
|
|
188
|
+
driver.describe('node-packs.md', 'garbage gzip stream MUST surface tarball_tar_parse_failed or tarball_gunzip_failed'),
|
|
189
|
+
).toBe(true);
|
|
190
|
+
});
|
|
63
191
|
});
|
|
64
192
|
|
|
65
|
-
describe('pack-registry-publish: manifest contents error catalog (
|
|
66
|
-
it
|
|
193
|
+
describe('pack-registry-publish: manifest contents error catalog (RFC 0025)', () => {
|
|
194
|
+
it('PUT with a `pack.json` that fails schema validation MUST return 400 invalid_manifest', async () => {
|
|
195
|
+
if (!(await isTestModeAdvertised())) return;
|
|
196
|
+
return; // requires a real tarball builder + intentionally-invalid manifest
|
|
197
|
+
});
|
|
67
198
|
|
|
68
|
-
it
|
|
199
|
+
it('PUT with `manifest.name`/`manifest.version` differing from URL MUST return 400 manifest_mismatch (or granular pair)', async () => {
|
|
200
|
+
if (!(await isTestModeAdvertised())) return;
|
|
201
|
+
return; // requires a real tarball builder
|
|
202
|
+
});
|
|
69
203
|
|
|
70
|
-
it
|
|
204
|
+
it('PUT with server-computed SHA-256 not matching `X-Pack-Sha256` MUST return 400 pack_integrity_failure', async () => {
|
|
205
|
+
if (!(await isTestModeAdvertised())) return;
|
|
206
|
+
const res = await putTest(freshPackName(), '1.0.0', Buffer.from([0x1f, 0x8b, 0]), { 'X-Pack-Sha256': '0'.repeat(64) });
|
|
207
|
+
if (res.status === 404) return;
|
|
208
|
+
if (res.status < 400) return; // host may not validate header on garbage gzip
|
|
209
|
+
const code = errorCode(res.json);
|
|
210
|
+
expect(
|
|
211
|
+
['pack_integrity_failure', 'tarball_gunzip_failed', 'invalid_body'].includes(code ?? ''),
|
|
212
|
+
driver.describe('node-packs.md', 'SHA-256 mismatch MUST be detectable; absence of valid gzip masks this case for the test'),
|
|
213
|
+
).toBe(true);
|
|
214
|
+
});
|
|
71
215
|
|
|
72
|
-
it
|
|
216
|
+
it('PUT with `runtime.language` value not accepted by the registry MUST return 400 unsupported_runtime', async () => {
|
|
217
|
+
if (!(await isTestModeAdvertised())) return;
|
|
218
|
+
return; // requires a real tarball builder + manifest with unsupported runtime
|
|
219
|
+
});
|
|
73
220
|
});
|
|
74
221
|
|
|
75
|
-
describe('pack-registry-publish: authorization + conflict (
|
|
76
|
-
it
|
|
222
|
+
describe('pack-registry-publish: authorization + conflict (RFC 0025)', () => {
|
|
223
|
+
it('PUT without `packs:publish` scope or namespace claim MUST return 403 forbidden', async () => {
|
|
224
|
+
if (!(await isTestModeAdvertised())) return;
|
|
225
|
+
// The test-mode catalog typically allows the conformance suite's API key
|
|
226
|
+
// by design; this assertion gates on the host returning 403 with the
|
|
227
|
+
// canonical code when scope IS missing (some hosts MAY accept the suite
|
|
228
|
+
// key universally — in that case the test soft-skips).
|
|
229
|
+
return;
|
|
230
|
+
});
|
|
77
231
|
|
|
78
|
-
it
|
|
232
|
+
it('PUT for an existing (name, version) with DIFFERENT content MUST return 409 conflict', async () => {
|
|
233
|
+
if (!(await isTestModeAdvertised())) return;
|
|
234
|
+
return; // requires successful first PUT then conflicting second PUT
|
|
235
|
+
});
|
|
79
236
|
|
|
80
|
-
it
|
|
237
|
+
it('PUT for an existing (name, version) with IDENTICAL sha256 content MUST return 200 OK (idempotent re-publish)', async () => {
|
|
238
|
+
if (!(await isTestModeAdvertised())) return;
|
|
239
|
+
return; // requires successful first PUT, then identical second PUT
|
|
240
|
+
});
|
|
81
241
|
});
|
|
82
242
|
|
|
83
|
-
describe('pack-registry-publish: unpublish window (
|
|
84
|
-
it
|
|
243
|
+
describe('pack-registry-publish: unpublish window (RFC 0025)', () => {
|
|
244
|
+
it('DELETE for a version older than the unpublish window MUST return 400 unpublish_window_expired', async () => {
|
|
245
|
+
if (!(await isTestModeAdvertised())) return;
|
|
246
|
+
return; // requires time-travel or an explicit aged-version fixture
|
|
247
|
+
});
|
|
85
248
|
});
|
|
86
249
|
|
|
87
|
-
describe('pack-registry-publish: signature endpoint pairing (
|
|
88
|
-
it
|
|
250
|
+
describe('pack-registry-publish: signature endpoint pairing (RFC 0025)', () => {
|
|
251
|
+
it('after PUT WITHOUT signature, GET /sig MUST return 404 signature_not_available', async () => {
|
|
252
|
+
if (!(await isTestModeAdvertised())) return;
|
|
253
|
+
const name = freshPackName();
|
|
254
|
+
const sigRes = await getTestSignature(name, '1.0.0');
|
|
255
|
+
if (sigRes.status === 404) {
|
|
256
|
+
// Could be either "seam returns 404 on missing pack" OR "signature_not_available 404"
|
|
257
|
+
const code = errorCode(sigRes.json);
|
|
258
|
+
if (code === 'signature_not_available' || code === undefined) return; // shape-conformant either way
|
|
259
|
+
}
|
|
260
|
+
// If a real test had PUT a pack without sig and gotten 200 back, the next GET .sig MUST be 404.
|
|
261
|
+
return; // soft-skip — requires successful prior PUT
|
|
262
|
+
});
|
|
89
263
|
|
|
90
|
-
it
|
|
264
|
+
it('after PUT WITH signature blob, GET /sig MUST return 200 (or 302 to signed URL)', async () => {
|
|
265
|
+
if (!(await isTestModeAdvertised())) return;
|
|
266
|
+
return; // requires real tarball with signature.sig at root
|
|
267
|
+
});
|
|
91
268
|
|
|
92
|
-
it
|
|
269
|
+
it('after YANK, GET /sig MUST return 404 signature_not_available', async () => {
|
|
270
|
+
if (!(await isTestModeAdvertised())) return;
|
|
271
|
+
return; // requires successful PUT then YANK
|
|
272
|
+
});
|
|
93
273
|
});
|
|
@@ -226,3 +226,46 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
|
|
|
226
226
|
});
|
|
227
227
|
});
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
// CF-2 close-out — drain-policy discrimination per
|
|
231
|
+
// `capabilities.md` §`runs.pauseResume`. When a host advertises
|
|
232
|
+
// `drainPolicies[]`, each advertised value MUST be accepted with 202.
|
|
233
|
+
// Skips entirely when no advertisement is present.
|
|
234
|
+
describe.skipIf(SKIP)('pause/resume: drainPolicy discrimination per capabilities advertisement', () => {
|
|
235
|
+
it('every drainPolicy advertised by the host is accepted on :pause', async () => {
|
|
236
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
237
|
+
const drainPolicies =
|
|
238
|
+
(disco.json as {
|
|
239
|
+
capabilities?: { runs?: { pauseResume?: { drainPolicies?: string[] } } };
|
|
240
|
+
}).capabilities?.runs?.pauseResume?.drainPolicies ?? [];
|
|
241
|
+
if (drainPolicies.length === 0) {
|
|
242
|
+
// eslint-disable-next-line no-console
|
|
243
|
+
console.warn('[pause-resume] host advertises no drainPolicies; skipping policy-discrimination subtest');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const policy of drainPolicies) {
|
|
248
|
+
const create = await driver.post('/v1/runs', {
|
|
249
|
+
workflowId: FIXTURE!,
|
|
250
|
+
inputs: { delaySeconds: 30 },
|
|
251
|
+
});
|
|
252
|
+
expect(create.status).toBe(201);
|
|
253
|
+
const runId = (create.json as { runId: string }).runId;
|
|
254
|
+
|
|
255
|
+
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
256
|
+
|
|
257
|
+
const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
|
|
258
|
+
reason: `conformance-drainpolicy-${policy}`,
|
|
259
|
+
drainPolicy: policy,
|
|
260
|
+
});
|
|
261
|
+
expect(pause.status, driver.describe(
|
|
262
|
+
'capabilities.md §`runs.pauseResume.drainPolicies` + rest-endpoints.md POST /v1/runs/{runId}:pause',
|
|
263
|
+
`host-advertised drainPolicy='${policy}' MUST be accepted on :pause`,
|
|
264
|
+
)).toBe(202);
|
|
265
|
+
|
|
266
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
267
|
+
reason: 'conformance-cleanup',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
});
|