From 88cbefea6b445c3d162d1d90ec8a88a1b872b619 Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Thu, 16 Apr 2026 18:07:47 +0200 Subject: [PATCH 1/4] support HTTP URLs in file fetcher and add system-test instrumentation Confidential workflows register HTTP URLs for the enclave. The file fetcher extracts the filename for local testing. Add relay capability constant and timing logs for system tests. --- .changeset/cw-fetcher-and-system-tests.md | 5 +++ core/services/workflows/syncer/fetcher.go | 3 ++ core/services/workflows/syncer/v2/fetcher.go | 5 +++ system-tests/lib/cre/features/mock/mock.go | 2 +- system-tests/lib/cre/types.go | 44 ++++++++++++-------- 5 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 .changeset/cw-fetcher-and-system-tests.md diff --git a/.changeset/cw-fetcher-and-system-tests.md b/.changeset/cw-fetcher-and-system-tests.md new file mode 100644 index 00000000000..52640f9d8e1 --- /dev/null +++ b/.changeset/cw-fetcher-and-system-tests.md @@ -0,0 +1,5 @@ +--- +"chainlink": patch +--- + +Support HTTP URLs in file fetcher for local confidential workflow testing, add system-test instrumentation #changed diff --git a/core/services/workflows/syncer/fetcher.go b/core/services/workflows/syncer/fetcher.go index c959b6b4da7..01de212507a 100644 --- a/core/services/workflows/syncer/fetcher.go +++ b/core/services/workflows/syncer/fetcher.go @@ -177,6 +177,9 @@ func newFileFetcher(basePath string, lggr logger.Logger) types.FetcherFunc { if err != nil { return nil, fmt.Errorf("invalid URL: %w", err) } + if u.Scheme == "http" || u.Scheme == "https" { + u.Path = filepath.Base(u.Path) + } fullPath := filepath.Clean(u.Path) // ensure that the incoming request URL is either relative or absolute but within the basePath diff --git a/core/services/workflows/syncer/v2/fetcher.go b/core/services/workflows/syncer/v2/fetcher.go index e86f998f6bb..cb334ff8fb8 100644 --- a/core/services/workflows/syncer/v2/fetcher.go +++ b/core/services/workflows/syncer/v2/fetcher.go @@ -211,6 +211,11 @@ func newFileFetcher(basePath string, lggr logger.Logger) types.FetcherFunc { if err != nil { return nil, fmt.Errorf("invalid URL: %w", err) } + // Confidential workflows register with HTTP URLs (for the enclave). + // Extract the filename so the file fetcher can find the local copy. + if u.Scheme == "http" || u.Scheme == "https" { + u.Path = filepath.Base(u.Path) + } fullPath := filepath.Clean(u.Path) // ensure that the incoming request URL is either relative or absolute but within the basePath diff --git a/system-tests/lib/cre/features/mock/mock.go b/system-tests/lib/cre/features/mock/mock.go index 4166243f7ff..a504a04d220 100644 --- a/system-tests/lib/cre/features/mock/mock.go +++ b/system-tests/lib/cre/features/mock/mock.go @@ -43,7 +43,7 @@ func (o *Mock) PreEnvStartup( Capability: kcr.CapabilitiesRegistryCapability{ LabelledName: "mock", Version: "1.0.0", - CapabilityType: 0, // TRIGGER + CapabilityType: 1, // ACTION }, Config: &capabilitiespb.CapabilityConfig{ LocalOnly: don.HasOnlyLocalCapabilities(), diff --git a/system-tests/lib/cre/types.go b/system-tests/lib/cre/types.go index fca444f2514..56358921a16 100644 --- a/system-tests/lib/cre/types.go +++ b/system-tests/lib/cre/types.go @@ -10,6 +10,7 @@ import ( "slices" "strconv" "strings" + "time" "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/common" @@ -56,23 +57,24 @@ const ( // Capabilities const ( - ConsensusCapability CapabilityFlag = "ocr3" - DONTimeCapability CapabilityFlag = "don-time" - ConsensusCapabilityV2 CapabilityFlag = "consensus" // v2 - CronCapability CapabilityFlag = "cron" - EVMCapability CapabilityFlag = "evm" - CustomComputeCapability CapabilityFlag = "custom-compute" - WriteEVMCapability CapabilityFlag = "write-evm" - ReadContractCapability CapabilityFlag = "read-contract" - LogEventTriggerCapability CapabilityFlag = "log-event-trigger" - WebAPITargetCapability CapabilityFlag = "web-api-target" - WebAPITriggerCapability CapabilityFlag = "web-api-trigger" - MockCapability CapabilityFlag = "mock" - VaultCapability CapabilityFlag = "vault" - HTTPTriggerCapability CapabilityFlag = "http-trigger" - HTTPActionCapability CapabilityFlag = "http-action" - SolanaCapability CapabilityFlag = "solana" - AptosCapability CapabilityFlag = "aptos" + ConsensusCapability CapabilityFlag = "ocr3" + DONTimeCapability CapabilityFlag = "don-time" + ConsensusCapabilityV2 CapabilityFlag = "consensus" // v2 + CronCapability CapabilityFlag = "cron" + EVMCapability CapabilityFlag = "evm" + CustomComputeCapability CapabilityFlag = "custom-compute" + WriteEVMCapability CapabilityFlag = "write-evm" + ReadContractCapability CapabilityFlag = "read-contract" + LogEventTriggerCapability CapabilityFlag = "log-event-trigger" + WebAPITargetCapability CapabilityFlag = "web-api-target" + WebAPITriggerCapability CapabilityFlag = "web-api-trigger" + MockCapability CapabilityFlag = "mock" + VaultCapability CapabilityFlag = "vault" + HTTPTriggerCapability CapabilityFlag = "http-trigger" + HTTPActionCapability CapabilityFlag = "http-action" + SolanaCapability CapabilityFlag = "solana" + ConfidentialRelayCapability CapabilityFlag = "confidential-relay" + AptosCapability CapabilityFlag = "aptos" // Add more capabilities as needed ) @@ -585,6 +587,7 @@ func NewDonMetadata(c *NodeSet, id uint64, provider infra.Provider, capabilityCo cfgs[i] = cfg } + newNodesStart := time.Now() nodes, err := newNodes(cfgs) if err != nil { return nil, fmt.Errorf("failed to create nodes metadata: %w", err) @@ -592,6 +595,7 @@ func NewDonMetadata(c *NodeSet, id uint64, provider infra.Provider, capabilityCo framework.L.Info(). Str("don", c.Name). Int("nodes", len(cfgs)). + Float64("duration_s", roundSeconds(time.Since(newNodesStart))). Msg("Node metadata generation completed") capConfigs, capErr := processCapabilityConfigs(c, capabilityConfigs) @@ -1462,6 +1466,7 @@ type NodeKeyInput struct { } func NewNodeKeys(input NodeKeyInput) (*secrets.NodeKeys, error) { + start := time.Now() out := &secrets.NodeKeys{ EVM: make(map[uint64]*crypto.EVMKey), Solana: make(map[string]*crypto.SolKey), @@ -1521,6 +1526,7 @@ func NewNodeKeys(input NodeKeyInput) (*secrets.NodeKeys, error) { Int("evm_chains", len(input.EVMChainIDs)). Int("solana_chains", len(input.SolanaChainIDs)). Bool("imported", input.ImportedSecrets != ""). + Float64("duration_s", roundSeconds(time.Since(start))). Msg("Node key generation completed") return out, nil } @@ -1693,3 +1699,7 @@ type PreEnvStartupOutput struct { // EVM is always included. CapabilityToExtraSignerFamilies map[string][]string } + +func roundSeconds(d time.Duration) float64 { + return float64(d.Milliseconds()) / 1000.0 +} From 396a390b942c62c95318b2fc9737c77d1324dbbe Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Thu, 16 Apr 2026 18:24:12 +0200 Subject: [PATCH 2/4] add confidential relay feature for system-tests --- .../confidentialrelay/confidentialrelay.go | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 system-tests/lib/cre/features/confidentialrelay/confidentialrelay.go diff --git a/system-tests/lib/cre/features/confidentialrelay/confidentialrelay.go b/system-tests/lib/cre/features/confidentialrelay/confidentialrelay.go new file mode 100644 index 00000000000..f6f72802ac3 --- /dev/null +++ b/system-tests/lib/cre/features/confidentialrelay/confidentialrelay.go @@ -0,0 +1,86 @@ +package confidentialrelay + +import ( + "context" + + tomlser "github.com/pelletier/go-toml/v2" + "github.com/pkg/errors" + "github.com/rs/zerolog" + + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink/deployment/cre/jobs/pkg" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre" + coretoml "github.com/smartcontractkit/chainlink/v2/core/config/toml" + corechainlink "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" +) + +const flag = cre.ConfidentialRelayCapability + +type ConfidentialRelay struct{} + +func (o *ConfidentialRelay) Flag() cre.CapabilityFlag { + return flag +} + +func (o *ConfidentialRelay) PreEnvStartup( + ctx context.Context, + testLogger zerolog.Logger, + don *cre.DonMetadata, + topology *cre.Topology, + creEnv *cre.Environment, +) (*cre.PreEnvStartupOutput, error) { + registryChainID, chErr := chainselectors.ChainIdFromSelector(creEnv.RegistryChainSelector) + if chErr != nil { + return nil, errors.Wrapf(chErr, "failed to get chain ID from selector %d", creEnv.RegistryChainSelector) + } + + hErr := topology.AddGatewayHandlers(*don, []string{pkg.GatewayHandlerTypeConfidentialRelay}) + if hErr != nil { + return nil, errors.Wrapf(hErr, "failed to add gateway handlers to gateway config for don %s", don.Name) + } + + cErr := don.ConfigureForGatewayAccess(registryChainID, *topology.GatewayConnectors) + if cErr != nil { + return nil, errors.Wrapf(cErr, "failed to add gateway connectors to node's TOML config for don %s", don.Name) + } + + // Set TOML config to activate the confidential relay handler on DON nodes. + capConfig, ok := don.CapabilityConfigs[flag] + if ok && capConfig.Values != nil { + ns := don.MustNodeSet() + for i := range ns.NodeSpecs { + currentConfig := ns.NodeSpecs[i].Node.TestConfigOverrides + var typedConfig corechainlink.Config + if currentConfig != "" { + if err := tomlser.Unmarshal([]byte(currentConfig), &typedConfig); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal node TOML config for node %d", i) + } + } + + enabled := true + typedConfig.CRE.ConfidentialRelay = &coretoml.ConfidentialRelayConfig{Enabled: &enabled} + + out, err := tomlser.Marshal(typedConfig) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal node TOML config for node %d", i) + } + ns.NodeSpecs[i].Node.TestConfigOverrides = string(out) + } + } + + // No on-chain capability registration needed. The relay handler is a CRE subservice, + // not a registered capability. The mock capability that runs on the relay DON is + // registered separately via the mock flag. + return &cre.PreEnvStartupOutput{}, nil +} + +func (o *ConfidentialRelay) PostEnvStartup( + ctx context.Context, + testLogger zerolog.Logger, + don *cre.Don, + dons *cre.Dons, + creEnv *cre.Environment, +) error { + return nil +} From 2b5bf52526386863a56c4978528053d486e74bd6 Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Thu, 16 Apr 2026 18:37:23 +0200 Subject: [PATCH 3/4] address review feedback: harden fetcher path validation, add tests, fix types.go --- core/services/workflows/syncer/fetcher.go | 5 ++- .../services/workflows/syncer/fetcher_test.go | 33 +++++++++++++++++++ core/services/workflows/syncer/v2/fetcher.go | 5 ++- .../workflows/syncer/v2/fetcher_test.go | 33 +++++++++++++++++++ system-tests/lib/cre/types.go | 3 +- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/core/services/workflows/syncer/fetcher.go b/core/services/workflows/syncer/fetcher.go index 01de212507a..6f63fab194d 100644 --- a/core/services/workflows/syncer/fetcher.go +++ b/core/services/workflows/syncer/fetcher.go @@ -179,6 +179,9 @@ func newFileFetcher(basePath string, lggr logger.Logger) types.FetcherFunc { } if u.Scheme == "http" || u.Scheme == "https" { u.Path = filepath.Base(u.Path) + if u.Path == "." || u.Path == "/" { + return nil, errors.New("HTTP URL has no filename in path") + } } fullPath := filepath.Clean(u.Path) @@ -187,7 +190,7 @@ func newFileFetcher(basePath string, lggr logger.Logger) types.FetcherFunc { // If it's not absolute, we assume it's relative to the basePath fullPath = filepath.Join(basePath, fullPath) } - if !strings.HasPrefix(fullPath, basePath) { + if !strings.HasPrefix(fullPath, basePath+string(filepath.Separator)) && fullPath != basePath { return nil, fmt.Errorf("request URL %s is not within the basePath %s", fullPath, basePath) } diff --git a/core/services/workflows/syncer/fetcher_test.go b/core/services/workflows/syncer/fetcher_test.go index 562ef2e77c8..bc98e8f9e3d 100644 --- a/core/services/workflows/syncer/fetcher_test.go +++ b/core/services/workflows/syncer/fetcher_test.go @@ -390,6 +390,39 @@ func TestNewFetcherFunc(t *testing.T) { assert.Equal(t, testContent, resp) }) + t.Run("file fetcher resolves HTTP URL to basename", func(t *testing.T) { + tempDir := t.TempDir() + err := os.WriteFile(filepath.Join(tempDir, "binary.wasm"), testContent, 0600) + require.NoError(t, err) + + fetcher, err := NewFetcherFunc("file://"+tempDir, lggr) + require.NoError(t, err) + + resp, err := fetcher(ctx, "msg-1", ghcapabilities.Request{ + URL: "http://storage.example.com/artifacts/binary.wasm", + }) + require.NoError(t, err) + assert.Equal(t, testContent, resp) + + resp, err = fetcher(ctx, "msg-2", ghcapabilities.Request{ + URL: "https://storage.example.com/path/to/binary.wasm", + }) + require.NoError(t, err) + assert.Equal(t, testContent, resp) + }) + + t.Run("file fetcher rejects HTTP URL with empty path", func(t *testing.T) { + tempDir := t.TempDir() + fetcher, err := NewFetcherFunc("file://"+tempDir, lggr) + require.NoError(t, err) + + _, err = fetcher(ctx, "msg-1", ghcapabilities.Request{ + URL: "http://storage.example.com", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP URL has no filename in path") + }) + t.Run("http fetcher", func(t *testing.T) { // Create test HTTP server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/core/services/workflows/syncer/v2/fetcher.go b/core/services/workflows/syncer/v2/fetcher.go index cb334ff8fb8..2314e496eb7 100644 --- a/core/services/workflows/syncer/v2/fetcher.go +++ b/core/services/workflows/syncer/v2/fetcher.go @@ -215,6 +215,9 @@ func newFileFetcher(basePath string, lggr logger.Logger) types.FetcherFunc { // Extract the filename so the file fetcher can find the local copy. if u.Scheme == "http" || u.Scheme == "https" { u.Path = filepath.Base(u.Path) + if u.Path == "." || u.Path == "/" { + return nil, errors.New("HTTP URL has no filename in path") + } } fullPath := filepath.Clean(u.Path) @@ -223,7 +226,7 @@ func newFileFetcher(basePath string, lggr logger.Logger) types.FetcherFunc { // If it's not absolute, we assume it's relative to the basePath fullPath = filepath.Join(basePath, fullPath) } - if !strings.HasPrefix(fullPath, basePath) { + if !strings.HasPrefix(fullPath, basePath+string(filepath.Separator)) && fullPath != basePath { return nil, fmt.Errorf("request URL %s is not within the basePath %s", fullPath, basePath) } diff --git a/core/services/workflows/syncer/v2/fetcher_test.go b/core/services/workflows/syncer/v2/fetcher_test.go index 79d684dcecb..d1195e8c5a7 100644 --- a/core/services/workflows/syncer/v2/fetcher_test.go +++ b/core/services/workflows/syncer/v2/fetcher_test.go @@ -439,6 +439,39 @@ func TestNewFetcherFunc(t *testing.T) { assert.Equal(t, testContent, resp) }) + t.Run("file fetcher resolves HTTP URL to basename", func(t *testing.T) { + tempDir := t.TempDir() + err := os.WriteFile(filepath.Join(tempDir, "binary.wasm"), testContent, 0600) + require.NoError(t, err) + + fetcher, err := NewFetcherFunc("file://"+tempDir, lggr) + require.NoError(t, err) + + resp, err := fetcher(ctx, "msg-1", ghcapabilities.Request{ + URL: "http://storage.example.com/artifacts/binary.wasm", + }) + require.NoError(t, err) + assert.Equal(t, testContent, resp) + + resp, err = fetcher(ctx, "msg-2", ghcapabilities.Request{ + URL: "https://storage.example.com/path/to/binary.wasm", + }) + require.NoError(t, err) + assert.Equal(t, testContent, resp) + }) + + t.Run("file fetcher rejects HTTP URL with empty path", func(t *testing.T) { + tempDir := t.TempDir() + fetcher, err := NewFetcherFunc("file://"+tempDir, lggr) + require.NoError(t, err) + + _, err = fetcher(ctx, "msg-1", ghcapabilities.Request{ + URL: "http://storage.example.com", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP URL has no filename in path") + }) + t.Run("http fetcher", func(t *testing.T) { // Create test HTTP server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/system-tests/lib/cre/types.go b/system-tests/lib/cre/types.go index 56358921a16..c03ab7e5809 100644 --- a/system-tests/lib/cre/types.go +++ b/system-tests/lib/cre/types.go @@ -1525,7 +1525,6 @@ func NewNodeKeys(input NodeKeyInput) (*secrets.NodeKeys, error) { framework.L.Debug(). Int("evm_chains", len(input.EVMChainIDs)). Int("solana_chains", len(input.SolanaChainIDs)). - Bool("imported", input.ImportedSecrets != ""). Float64("duration_s", roundSeconds(time.Since(start))). Msg("Node key generation completed") return out, nil @@ -1701,5 +1700,5 @@ type PreEnvStartupOutput struct { } func roundSeconds(d time.Duration) float64 { - return float64(d.Milliseconds()) / 1000.0 + return d.Seconds() } From 1072792c9ad2d9b3de0c7d8192b64dab4a972b5e Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Fri, 17 Apr 2026 12:25:16 +0200 Subject: [PATCH 4/4] inline Seconds() and remove roundSeconds helper --- system-tests/lib/cre/types.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/system-tests/lib/cre/types.go b/system-tests/lib/cre/types.go index c03ab7e5809..b76e113ea0f 100644 --- a/system-tests/lib/cre/types.go +++ b/system-tests/lib/cre/types.go @@ -595,7 +595,7 @@ func NewDonMetadata(c *NodeSet, id uint64, provider infra.Provider, capabilityCo framework.L.Info(). Str("don", c.Name). Int("nodes", len(cfgs)). - Float64("duration_s", roundSeconds(time.Since(newNodesStart))). + Float64("duration_s", time.Since(newNodesStart).Seconds()). Msg("Node metadata generation completed") capConfigs, capErr := processCapabilityConfigs(c, capabilityConfigs) @@ -1525,7 +1525,7 @@ func NewNodeKeys(input NodeKeyInput) (*secrets.NodeKeys, error) { framework.L.Debug(). Int("evm_chains", len(input.EVMChainIDs)). Int("solana_chains", len(input.SolanaChainIDs)). - Float64("duration_s", roundSeconds(time.Since(start))). + Float64("duration_s", time.Since(start).Seconds()). Msg("Node key generation completed") return out, nil } @@ -1698,7 +1698,3 @@ type PreEnvStartupOutput struct { // EVM is always included. CapabilityToExtraSignerFamilies map[string][]string } - -func roundSeconds(d time.Duration) float64 { - return d.Seconds() -}