This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 0801a69fd3 Give each execution its own VFS namespace, fixes #8106 
(#8119)
0801a69fd3 is described below

commit 0801a69fd34859c66dbbcab6f0a38fbde91410cd
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Aug 27 14:08:43 2026 +0200

    Give each execution its own VFS namespace, fixes #8106 (#8119)
---
 .../java/org/apache/hop/core/scope/IHopScope.java  | 121 +++++++++
 .../main/java/org/apache/hop/core/vfs/HopVfs.java  | 114 +++++++-
 .../org/apache/hop/core/vfs/HopVfsNamespace.java   | 161 ++++++++++++
 .../org/apache/hop/core/vfs/HopVfsNamespaces.java  | 292 +++++++++++++++++++++
 .../java/org/apache/hop/core/vfs/plugin/IVfs.java  |  19 ++
 .../hop/metadata/util/HopMetadataInstance.java     |  27 +-
 .../variables/VariablesMetadataAnchorTest.java     |  60 +++++
 .../hop/core/vfs/HopVfsNamespaceScopingTest.java   | 238 +++++++++++++++++
 .../hop/core/vfs/HopVfsNamespaceThreadTest.java    | 187 +++++++++++++
 .../integration-tests-hop_server.yaml              |  30 ++-
 .../java/org/apache/hop/pipeline/Pipeline.java     |  51 ++++
 .../java/org/apache/hop/workflow/Workflow.java     |  28 ++
 .../hop/pipeline/PipelineVfsNamespaceTest.java     |  78 ++++++
 .../hop_server/0014-write-to-export-only-vfs.hpl   | 153 +++++++++++
 .../hop_server/0014-write-to-export-only-vfs.hwf   |  93 +++++++
 .../main-0014-test-export-only-vfs-connection.hwf  | 159 +++++++++++
 .../export-only-storage.json                       |  12 +
 .../apache/hop/projects/gui/ProjectsGuiPlugin.java |   6 +-
 .../java/org/apache/hop/vfs/s3/S3VfsPlugin.java    |  10 +-
 .../org/apache/hop/vfs/s3/metadata/S3Meta.java     |   3 +-
 .../apache/hop/vfs/s3/metadata/S3MetaEditor.java   |   2 +-
 .../org/apache/hop/vfs/azure/AzureVfsPlugin.java   |  13 +-
 .../vfs/azure/metadatatype/AzureMetadataType.java  |   3 +-
 .../metadatatype/AzureMetadataTypeEditor.java      |   2 +-
 .../databricks/metadata/DatabricksConnection.java  |   3 +-
 .../hop/vfs/databricks/DatabricksVfsPlugin.java    |  13 +-
 .../metadata/DatabricksVfsConnection.java          |   3 +-
 .../java/org/apache/hop/vfs/ftp/FtpVfsPlugin.java  |  10 +-
 .../apache/hop/vfs/ftp/metadata/FtpConnection.java |   3 +-
 .../hop/vfs/ftp/metadata/FtpConnectionEditor.java  |   2 +-
 .../apache/hop/vfs/gs/GoogleStorageVfsPlugin.java  |  10 +-
 .../gs/metadatatype/GoogleStorageMetadataType.java |   3 +-
 .../GoogleStorageMetadataTypeEditor.java           |   2 +-
 .../org/apache/hop/vfs/minio/MinioVfsPlugin.java   |  17 +-
 .../apache/hop/vfs/minio/metadata/MinioMeta.java   |   3 +-
 .../hop/vfs/minio/metadata/MinioMetaEditor.java    |   2 +-
 .../apache/hop/vfs/minio/MinioVfsPluginTest.java   |  23 +-
 .../org/apache/hop/vfs/sftp/SftpVfsPlugin.java     |  10 +-
 .../hop/vfs/sftp/metadata/SftpConnection.java      |   3 +-
 .../vfs/sftp/metadata/SftpConnectionEditor.java    |   2 +-
 .../org/apache/hop/vfs/webdav/WebDavVfsPlugin.java |  10 +-
 .../hop/vfs/webdav/metadata/WebDavConnection.java  |   3 +-
 .../webdav/metadata/WebDavConnectionEditor.java    |   2 +-
 .../main/java/org/apache/hop/ui/hopgui/HopWeb.java |   6 +
 .../org/apache/hop/ui/hopgui/HopWebEntryPoint.java |   8 +-
 .../org/apache/hop/ui/hopgui/RapSessionScope.java  | 115 ++++++++
 .../apache/hop/ui/hopgui/RapSessionScopeTest.java  |  66 +++++
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |  77 +++++-
 48 files changed, 2212 insertions(+), 46 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/scope/IHopScope.java 
b/core/src/main/java/org/apache/hop/core/scope/IHopScope.java
new file mode 100644
index 0000000000..8f24c40161
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/scope/IHopScope.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.scope;
+
+/**
+ * Holds one "current" value, for the ambient state Hop keeps outside of any 
object it can hand
+ * around: the metadata provider in use, the VFS namespace to resolve files in.
+ *
+ * <p>How far "current" reaches depends on how Hop is running, and that is the 
only part that
+ * differs. In a client, or in a server for state set once at startup, it is 
the whole process. For
+ * state an execution owns it is the thread and the threads it starts. In Hop 
Web one JVM serves
+ * many people at once and neither answer is right: there it has to be the 
session, or one user's
+ * project silently becomes another's.
+ *
+ * <p>Each piece of ambient state owns its scope and picks the default that 
suits it - see {@link
+ * #process()} and {@link #inheritedByThreads()}. A runtime that needs 
different reach implements
+ * this once and hands an instance to each of them.
+ *
+ * @param <T> the type of the value held
+ */
+public interface IHopScope<T> {
+
+  /**
+   * The current value, or null when nothing is set.
+   *
+   * @return the current value, may be null
+   */
+  T get();
+
+  /**
+   * Set the current value.
+   *
+   * @param value the value to set
+   */
+  void set(T value);
+
+  /** Clear the current value. */
+  void remove();
+
+  /**
+   * A scope reaching the whole process: one value, shared by everything. The 
right default for
+   * state that is set once at startup.
+   *
+   * @param <T> the type of the value held
+   * @return a new process wide scope
+   */
+  static <T> IHopScope<T> process() {
+    return new ProcessScope<>();
+  }
+
+  /**
+   * A scope reaching the current thread and the threads it starts. The right 
default for state an
+   * execution owns: {@code Pipeline} and {@code Workflow} hand their work to 
threads they start
+   * themselves, so those inherit it, while a second execution running 
alongside keeps its own.
+   *
+   * @param <T> the type of the value held
+   * @return a new thread scope
+   */
+  static <T> IHopScope<T> inheritedByThreads() {
+    return new ThreadScope<>();
+  }
+
+  /**
+   * @see IHopScope#process()
+   */
+  class ProcessScope<T> implements IHopScope<T> {
+    private volatile T value;
+
+    @Override
+    public T get() {
+      return value;
+    }
+
+    @Override
+    public void set(T value) {
+      this.value = value;
+    }
+
+    @Override
+    public void remove() {
+      this.value = null;
+    }
+  }
+
+  /**
+   * @see IHopScope#inheritedByThreads()
+   */
+  class ThreadScope<T> implements IHopScope<T> {
+    private final InheritableThreadLocal<T> value = new 
InheritableThreadLocal<>();
+
+    @Override
+    public T get() {
+      return value.get();
+    }
+
+    @Override
+    public void set(T value) {
+      this.value.set(value);
+    }
+
+    @Override
+    public void remove() {
+      value.remove();
+    }
+  }
+}
diff --git a/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java 
b/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
index 6eb3f7d575..2928896d9f 100644
--- a/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
+++ b/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
@@ -54,6 +54,8 @@ import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.plugin.IVfs;
 import org.apache.hop.core.vfs.plugin.VfsPluginType;
 import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataInstance;
 
 public class HopVfs {
   private static final Class<?> PKG = HopVfs.class;
@@ -73,6 +75,12 @@ public class HopVfs {
   /** Set once the metadata driven providers have been registered on {@link 
#fsm}. */
   private static boolean namedProvidersRegistered;
 
+  /**
+   * The metadata the named connections on {@link #fsm} were read from, or 
null while nothing has
+   * registered any yet.
+   */
+  private static IHopMetadataProvider defaultNamespaceProvider;
+
   /**
    * Guards against re-entrant registration: reading the VFS connection 
metadata resolves files
    * through this very class.
@@ -138,6 +146,11 @@ public class HopVfs {
       registeringNamedProviders = true;
       try {
         registerNamedProviders(fsm, bootstrapVariables);
+        // Remember whose connections these are. Everything running against 
this same metadata can
+        // use this manager and needs no namespace of its own; everything else 
does. Note it is the
+        // provider the manager was *built from* that matters, not whatever is 
current later on:
+        // in Hop Web "current" is per session, and would stop describing this 
shared manager.
+        defaultNamespaceProvider = HopMetadataInstance.getMetadataProvider();
       } finally {
         registeringNamedProviders = false;
         namedProvidersRegistered = true;
@@ -154,6 +167,13 @@ public class HopVfs {
    * @see #getFileSystemManager()
    */
   public static synchronized DefaultFileSystemManager 
getFileSystemManager(IVariables variables) {
+    // An execution running against its own metadata - an export on a Hop 
Server - has its own
+    // namespace, with its own named VFS connections. Everything else uses the 
process wide
+    // manager below, exactly as before. See issue #8106.
+    HopVfsNamespace namespace = HopVfsNamespaces.resolve(variables);
+    if (namespace != null) {
+      return namespace.getFileSystemManager();
+    }
     bootstrapWith(variables);
     return getFileSystemManager();
   }
@@ -177,11 +197,27 @@ public class HopVfs {
    */
   private static void registerNamedProviders(
       DefaultFileSystemManager manager, IVariables variables) {
+    registerNamedProviders(manager, variables, null);
+  }
+
+  /**
+   * Register a provider for every named VFS connection, reading them from 
{@code metadataProvider}
+   * when one is given. A null provider leaves every VFS plugin to find the 
metadata itself from the
+   * variables, which is what the process wide manager does.
+   *
+   * @param manager the manager to register the providers on
+   * @param variables the variables the providers resolve their settings with
+   * @param metadataProvider the metadata holding the connections, or null to 
derive it
+   */
+  static void registerNamedProviders(
+      DefaultFileSystemManager manager,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider) {
     PluginRegistry registry = PluginRegistry.getInstance();
     for (IPlugin plugin : registry.getPlugins(VfsPluginType.class)) {
       try {
         IVfs iVfs = registry.loadClass(plugin, IVfs.class);
-        Map<String, FileProvider> fileProviderMap = 
iVfs.getProviders(variables);
+        Map<String, FileProvider> fileProviderMap = 
iVfs.getProviders(variables, metadataProvider);
         if (fileProviderMap == null) {
           continue;
         }
@@ -193,7 +229,11 @@ public class HopVfs {
                     + scheme
                     + "' of plugin "
                     + plugin.getIds()[0]
-                    + " is ignored: a provider is already registered for that 
scheme.");
+                    + " is ignored: a provider is already registered for that 
scheme."
+                    + " Two named connections can not share a name: rename one 
of them,"
+                    + " otherwise files resolved through '"
+                    + scheme
+                    + ":' silently use the other connection.");
             continue;
           }
           manager.addProvider(scheme, entry.getValue());
@@ -216,7 +256,17 @@ public class HopVfs {
    * @throws HopException
    */
   @SuppressWarnings("java:S2095") // the file system manager is a process-wide 
singleton
-  private static DefaultFileSystemManager createFileSystemManager() throws 
HopException {
+  /**
+   * The metadata whose named VFS connections are registered on the process 
wide file system
+   * manager. Anything running against this same metadata is already served by 
it.
+   *
+   * @return the metadata behind the process wide manager, or null if nothing 
registered yet
+   */
+  static synchronized IHopMetadataProvider getDefaultNamespaceProvider() {
+    return defaultNamespaceProvider;
+  }
+
+  static DefaultFileSystemManager createFileSystemManager() throws 
HopException {
     try {
       DefaultFileSystemManager fsm = new DefaultFileSystemManager();
       fsm.addProvider("ram", new 
org.apache.commons.vfs2.provider.ram.RamFileProvider());
@@ -292,20 +342,32 @@ public class HopVfs {
   }
 
   /**
+   * Resolve a file in the VFS namespace these variables belong to.
+   *
+   * <p>Prefer this over {@link #getFileObject(String)} anywhere inside an 
execution: the variables
+   * are what tell us which named VFS connections apply. An execution carrying 
its own metadata - an
+   * export running on a Hop Server - has its own connections, and resolving 
without variables can
+   * only fall back to the namespace bound to the current thread.
+   *
    * @param vfsFilename the name of the file to resolve
-   * @param variables the variables to bootstrap the metadata driven providers 
with, in case nothing
-   *     did that yet. They play no role in resolving the file itself.
+   * @param variables the variables of the caller
    * @return the file object
    * @see #getFileObject(String)
    */
   public static FileObject getFileObject(String vfsFilename, IVariables 
variables)
       throws HopFileException {
-    bootstrapWith(variables);
-    return getFileObject(vfsFilename);
+    return resolveWith(vfsFilename, getFileSystemManager(variables));
   }
 
   public static synchronized FileObject getFileObject(String vfsFilename) 
throws HopFileException {
-    DefaultFileSystemManager fsManager = getFileSystemManager();
+    // Nothing to go on but the thread: the namespace of the execution running 
on it, if any.
+    HopVfsNamespace namespace = HopVfsNamespaces.getCurrent();
+    return resolveWith(
+        vfsFilename, namespace == null ? getFileSystemManager() : 
namespace.getFileSystemManager());
+  }
+
+  private static FileObject resolveWith(String vfsFilename, 
DefaultFileSystemManager fsManager)
+      throws HopFileException {
 
     try {
       // We have one problem with VFS: if the file is in a subdirectory of the 
current one:
@@ -785,7 +847,43 @@ public class HopVfs {
    * the next time it's used. The bootstrap variables are kept: use {@link
    * #setBootstrapVariables(IVariables)} to change those.
    */
+  /**
+   * Read the named VFS connections again for whoever these variables belong 
to, after one of them
+   * was added or changed.
+   *
+   * <p>Use this rather than {@link #reset()} from anything that belongs to 
one project, one session
+   * or one execution - saving a connection in its editor, switching project. 
{@link #reset()}
+   * empties the whole JVM, which in Hop Web means one user's save 
invalidating every open file of
+   * everyone else.
+   *
+   * @param variables the variables of whoever changed a connection
+   */
+  public static void refresh(IVariables variables) {
+    try {
+      if (HopVfsNamespaces.refresh(variables)) {
+        return;
+      }
+    } catch (Exception e) {
+      // Deliberately not falling back to reset(): the caller has a namespace 
of its own, which
+      // means tenants share this JVM, and emptying it for all of them is the 
very thing this
+      // avoids. Their connections stay as they were until the next attempt.
+      LogChannel.GENERAL.logError(
+          "Error re-reading the named VFS connections of this caller. They are 
unchanged.", e);
+      return;
+    }
+    // Nothing of their own to refresh: what they resolve files in is the 
process wide manager.
+    reset();
+  }
+
+  /**
+   * Throw away the process wide file system manager and everything in it, so 
the next use builds it
+   * again. Every file object anywhere in the JVM stops working, so keep this 
for process level
+   * events - starting a client, starting a worker. Everything else wants 
{@link
+   * #refresh(IVariables)}.
+   */
   public static synchronized void reset() {
+    HopVfsNamespaces.reset();
+    defaultNamespaceProvider = null;
     if (fsm != null) {
       fsm.freeUnusedResources();
       fsm.close();
diff --git a/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespace.java 
b/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespace.java
new file mode 100644
index 0000000000..eebbaccbaa
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespace.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.vfs;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.commons.vfs2.impl.DefaultFileSystemManager;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+
+/**
+ * A private VFS world: one {@link DefaultFileSystemManager} carrying the 
fixed schemes plus the
+ * named VFS connections of exactly one metadata provider.
+ *
+ * <p>Named connections are registered as URI schemes, and the scheme is the 
connection's name. On a
+ * shared JVM - a Hop Server running exports for several people, or Hop Web 
serving several sessions
+ * - one manager therefore means one global namespace: the first connection 
called {@code mydata}
+ * wins and everybody else silently gets it. A namespace per metadata provider 
keeps the scheme
+ * table, the provider instances, the files cache and the replicator apart.
+ *
+ * <p>Instances are handed out by {@link HopVfsNamespaces}, never constructed 
directly.
+ *
+ * @see HopVfsNamespaces
+ */
+public class HopVfsNamespace implements AutoCloseable {
+
+  private final String description;
+
+  /** Swapped for a fresh one by {@link #rebuild()}; read without locking, so 
volatile. */
+  private volatile DefaultFileSystemManager fileSystemManager;
+
+  /** What the named connections were registered from, so this namespace can 
rebuild itself. */
+  private IVariables variables;
+
+  private IHopMetadataProvider metadataProvider;
+
+  /** How many executions are using this namespace; it closes when the last 
one lets go. */
+  private int useCount;
+
+  HopVfsNamespace(String description) throws HopException {
+    this.description = description;
+    this.fileSystemManager = HopVfs.createFileSystemManager();
+    try {
+      this.fileSystemManager.init();
+    } catch (Exception e) {
+      throw new HopException("Error initializing the file system manager of " 
+ description, e);
+    }
+  }
+
+  /**
+   * Register the named VFS connections of this namespace's metadata. Called 
once, by {@link
+   * HopVfsNamespaces}, after the namespace is published so re-entrant 
resolution finds it.
+   */
+  void registerNamedProviders(IVariables variables, IHopMetadataProvider 
metadataProvider) {
+    this.variables = variables;
+    this.metadataProvider = metadataProvider;
+
+    Set<String> before = new 
HashSet<>(Arrays.asList(fileSystemManager.getSchemes()));
+    HopVfs.registerNamedProviders(fileSystemManager, variables, 
metadataProvider);
+    List<String> registered =
+        Arrays.stream(fileSystemManager.getSchemes())
+            .filter(scheme -> !before.contains(scheme))
+            .sorted()
+            .toList();
+    // Worth saying out loud: an empty list here is why a path like 
'mydata://...' quietly turns
+    // into a relative local file instead of reaching the object store.
+    LogChannel.GENERAL.logBasic(
+        "VFS namespace of " + description + " : named connections registered " 
+ registered);
+  }
+
+  /**
+   * Read the named connections again into a fresh file system manager, after 
one of them changed.
+   *
+   * <p>A provider holds the settings of its connection as they were when it 
was registered, and a
+   * manager gives no way to replace one, so the whole manager is built again. 
Every {@link
+   * org.apache.commons.vfs2.FileObject} resolved through the old one stops 
working - which is what
+   * makes this worth scoping to the namespace whose connections actually 
changed, rather than doing
+   * it to every namespace in the JVM.
+   *
+   * <p>The namespace itself survives, so whoever holds it keeps working and 
the use count stands.
+   *
+   * @throws HopException if the new manager cannot be created
+   */
+  void rebuild() throws HopException {
+    DefaultFileSystemManager previous = fileSystemManager;
+    DefaultFileSystemManager fresh = HopVfs.createFileSystemManager();
+    try {
+      fresh.init();
+    } catch (Exception e) {
+      throw new HopException("Error initializing the new file system manager 
of " + description, e);
+    }
+    fileSystemManager = fresh;
+    if (metadataProvider != null) {
+      registerNamedProviders(variables, metadataProvider);
+    }
+    closeManager(previous);
+  }
+
+  public DefaultFileSystemManager getFileSystemManager() {
+    return fileSystemManager;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+
+  int retain() {
+    return ++useCount;
+  }
+
+  int release() {
+    return --useCount;
+  }
+
+  int getUseCount() {
+    return useCount;
+  }
+
+  /**
+   * Close the manager and everything it holds. Only the owner of the last use 
closes a namespace:
+   * every {@link org.apache.commons.vfs2.FileObject} resolved through it dies 
with it, and result
+   * files carry those objects out of an execution.
+   */
+  @Override
+  public void close() {
+    closeManager(fileSystemManager);
+  }
+
+  private void closeManager(DefaultFileSystemManager manager) {
+    try {
+      manager.freeUnusedResources();
+      manager.close();
+    } catch (Exception e) {
+      LogChannel.GENERAL.logError("Error closing the file system manager of " 
+ description, e);
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "VFS namespace of " + description;
+  }
+}
diff --git a/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespaces.java 
b/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespaces.java
new file mode 100644
index 0000000000..66d9dc1542
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespaces.java
@@ -0,0 +1,292 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.vfs;
+
+import java.util.IdentityHashMap;
+import java.util.Map;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.scope.IHopScope;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataInstance;
+
+/**
+ * Hands out {@link HopVfsNamespace}s, one per metadata provider.
+ *
+ * <p>What decides the meaning of a named VFS connection is not the thread, 
the session or the
+ * execution: it is the metadata the connection is read from. Runs that share 
a metadata provider
+ * genuinely share a definition and share a namespace; anything running 
against its own metadata -
+ * an export on a Hop Server carries its own, bundled in the ZIP - gets its 
own.
+ *
+ * <p>Everything running against the metadata the process was started with 
keeps using the process
+ * wide manager of {@link HopVfs}, so nothing changes for Hop GUI, {@code 
hop-run}, or a server
+ * running a pipeline by filename.
+ *
+ * <p>See Apache Hop issue #8106.
+ */
+public class HopVfsNamespaces {
+
+  /**
+   * Keyed on provider identity, not equality: two providers holding the same 
connections are still
+   * two namespaces. Deliberate - equality would have to compare whole 
metadata trees.
+   */
+  private static final Map<IHopMetadataProvider, HopVfsNamespace> NAMESPACES =
+      new IdentityHashMap<>();
+
+  /**
+   * Where the current namespace lives, for the call sites that resolve a file 
without any variables
+   * in hand. Per thread unless something replaces it: {@code Pipeline} and 
{@code Workflow} spawn
+   * their threads with a plain {@code new Thread(...)}, so the threads of an 
execution inherit the
+   * namespace it bound. Hop Web replaces this with a per session scope - see 
{@link
+   * IHopVfsNamespaceScope}.
+   *
+   * <p>This is the fallback. Where an {@link IVariables} is available, 
resolving through it is
+   * exact and does not depend on where the work ended up running.
+   */
+  private static IHopScope<HopVfsNamespace> scope = 
IHopScope.inheritedByThreads();
+
+  /**
+   * Change how far "current" reaches. Call this once at startup, before 
anything resolves a file:
+   * Hop Web installs a per session scope here - the same one it installs on 
{@link
+   * org.apache.hop.metadata.util.HopMetadataInstance} - and everything else 
leaves the per thread
+   * default in place.
+   *
+   * @param newScope the scope to hold the current namespace in, null restores 
the default
+   */
+  public static synchronized void setScope(IHopScope<HopVfsNamespace> 
newScope) {
+    scope = newScope == null ? IHopScope.inheritedByThreads() : newScope;
+    // A runtime only installs a scope of its own when several tenants share 
the JVM. There, no
+    // tenant may fall back to the process wide manager: they would share it, 
and one of them
+    // rebuilding its connections would take the others down with it.
+    isolateEverything = newScope != null;
+  }
+
+  /** Set when the runtime serves several tenants at once, so nobody shares 
the process manager. */
+  private static boolean isolateEverything;
+
+  private HopVfsNamespaces() {
+    // Utility class
+  }
+
+  /**
+   * The namespace to resolve a file with, or null when the process wide 
manager of {@link HopVfs}
+   * applies.
+   *
+   * @param variables the variables of the caller, may be null
+   * @return the namespace to use, or null for the process wide manager
+   */
+  public static HopVfsNamespace resolve(IVariables variables) {
+    IHopMetadataProvider provider =
+        variables == null ? null : variables.findExecutionMetadataProvider();
+
+    // Running against the metadata the process was set up with? Then the 
process wide manager
+    // already holds exactly these connections and there is nothing to isolate.
+    if (provider != null && !isProcessMetadata(provider)) {
+      HopVfsNamespace namespace = existing(provider);
+      if (namespace != null) {
+        return namespace;
+      }
+      // Metadata of its own, but nobody took a namespace for it. Resolution 
falls back below, so
+      // a named connection carried by this metadata is about to be missed. 
Nothing is wired to
+      // acquire() on this path: worth knowing which one it is.
+      if (LogChannel.GENERAL.isDebug()) {
+        LogChannel.GENERAL.logDebug(
+            "Resolving a file against metadata with no VFS namespace of its 
own: "
+                + provider.getDescription()
+                + ". Its named VFS connections are not registered.");
+      }
+    }
+    return scope.get();
+  }
+
+  /** The namespace bound to this thread, or null. */
+  public static HopVfsNamespace getCurrent() {
+    return scope.get();
+  }
+
+  /**
+   * Take the namespace of a context running against its own metadata, 
creating it if this is the
+   * first user, and return it. Every caller that gets a namespace back must 
{@link #release} it
+   * when it is done - an execution when it finishes, a Hop Web session when 
it is disposed.
+   *
+   * <p>Returns null - and takes nothing - when the context runs against the 
metadata of the process
+   * itself, which is the ordinary case for Hop GUI, {@code hop-run} and a 
server running a pipeline
+   * by filename. Those keep using the process wide manager of {@link HopVfs}.
+   *
+   * @param variables the variables the connections resolve their settings with
+   * @param metadataProvider the metadata this context runs against
+   * @param description what this namespace belongs to, for logging
+   * @return the namespace, or null when the process wide manager applies
+   */
+  public static synchronized HopVfsNamespace acquire(
+      IVariables variables, IHopMetadataProvider metadataProvider, String 
description) {
+    if (metadataProvider == null || isProcessMetadata(metadataProvider)) {
+      LogChannel.GENERAL.logDebug(
+          "No separate VFS namespace for "
+              + description
+              + ": it runs against the metadata of the process itself");
+      return null;
+    }
+
+    HopVfsNamespace namespace = NAMESPACES.get(metadataProvider);
+    if (namespace == null) {
+      try {
+        namespace = new HopVfsNamespace(description);
+      } catch (HopException e) {
+        LogChannel.GENERAL.logError(
+            "Unable to create a VFS namespace for "
+                + description
+                + ", falling back to the process wide file system manager",
+            e);
+        return null;
+      }
+      // Publish before registering: registering the named connections reads 
metadata, which
+      // resolves files, which lands right back in resolve().
+      NAMESPACES.put(metadataProvider, namespace);
+      namespace.registerNamedProviders(variables, metadataProvider);
+      LogChannel.GENERAL.logBasic(
+          "Created a VFS namespace for "
+              + description
+              + ", holding the named VFS connections of "
+              + metadataProvider.getDescription());
+    }
+    namespace.retain();
+    return namespace;
+  }
+
+  /**
+   * Let go of a namespace taken with {@link #acquire}. It is closed once 
nothing is using it any
+   * more: every file object resolved through it dies with it, so release only 
after the results of
+   * the execution have been dealt with.
+   *
+   * @param metadataProvider the metadata the namespace was taken for
+   */
+  public static synchronized void release(IHopMetadataProvider 
metadataProvider) {
+    if (metadataProvider == null) {
+      return;
+    }
+    HopVfsNamespace namespace = NAMESPACES.get(metadataProvider);
+    if (namespace == null) {
+      return;
+    }
+    if (namespace.release() <= 0) {
+      NAMESPACES.remove(metadataProvider);
+      namespace.close();
+    }
+  }
+
+  /**
+   * Make a namespace the one for this thread and the threads it starts, so 
the call sites that
+   * resolve a file without variables land in it too.
+   *
+   * <p>Returns whatever was bound before, which the caller must hand back to 
{@link #restoreThread}
+   * in a finally. Executions nest - a pipeline inside a workflow, a resolver 
pipeline inside a
+   * transform - and they run on the same thread, so a nested execution that 
simply cleared the
+   * binding on its way out would leave the rest of its parent resolving files 
in the wrong
+   * namespace.
+   *
+   * <p>A null namespace leaves the binding untouched: an execution running 
against the metadata of
+   * the process has nothing of its own to bind, and must not drop what its 
parent bound. Threads
+   * already started keep the namespace they inherited either way.
+   *
+   * @param namespace the namespace to bind, or null to leave the binding as 
it is
+   * @return the namespace that was bound before, to pass to {@link 
#restoreThread}
+   */
+  public static HopVfsNamespace bindThread(HopVfsNamespace namespace) {
+    HopVfsNamespace previous = scope.get();
+    if (namespace != null) {
+      scope.set(namespace);
+    }
+    return previous;
+  }
+
+  /**
+   * Put back what {@link #bindThread} returned.
+   *
+   * @param previous the namespace that was bound before, may be null
+   */
+  public static void restoreThread(HopVfsNamespace previous) {
+    if (previous == null) {
+      scope.remove();
+    } else {
+      scope.set(previous);
+    }
+  }
+
+  /**
+   * Is this the metadata the process wide file system manager already holds 
the connections of?
+   *
+   * <p>Compared against the provider that manager was built from, not against 
whatever is current
+   * now: {@link HopMetadataInstance} reaches only the session in Hop Web, so 
the current provider
+   * there says nothing about a manager shared by every session. Before 
anything has registered, the
+   * current provider is the best answer available and matches how the manager 
is about to be built.
+   */
+  private static boolean isProcessMetadata(IHopMetadataProvider provider) {
+    if (isolateEverything) {
+      return false;
+    }
+    IHopMetadataProvider behindTheManager = 
HopVfs.getDefaultNamespaceProvider();
+    if (behindTheManager != null) {
+      return provider == behindTheManager;
+    }
+    return provider == HopMetadataInstance.getMetadataProvider();
+  }
+
+  /**
+   * Read the named connections again for the namespace these variables 
resolve files in, after one
+   * of them changed.
+   *
+   * <p>Scoped on purpose: rebuilding invalidates every file object of that 
namespace, and in a JVM
+   * shared by several tenants - Hop Web - the others must not notice. Returns 
false when the caller
+   * has no namespace of its own and the process wide manager is what needs 
rebuilding instead.
+   *
+   * @param variables the variables of whoever changed a connection
+   * @return true when a namespace was rebuilt
+   * @throws HopException if the namespace cannot be rebuilt
+   */
+  public static synchronized boolean refresh(IVariables variables) throws 
HopException {
+    HopVfsNamespace namespace = resolve(variables);
+    if (namespace == null) {
+      return false;
+    }
+    namespace.rebuild();
+    return true;
+  }
+
+  private static synchronized HopVfsNamespace existing(IHopMetadataProvider 
provider) {
+    return NAMESPACES.get(provider);
+  }
+
+  /**
+   * Close every namespace and unbind the current one. For tests and for a 
clean shutdown.
+   *
+   * <p>Leaves the installed scope in place: which runtime this is does not 
change because the
+   * namespaces were closed. A test that installed one puts it back with 
{@code setScope(null)}.
+   */
+  public static synchronized void reset() {
+    NAMESPACES.values().forEach(HopVfsNamespace::close);
+    NAMESPACES.clear();
+    scope.remove();
+  }
+
+  /** How many namespaces are open right now. For tests and diagnostics. */
+  public static synchronized int size() {
+    return NAMESPACES.size();
+  }
+}
diff --git a/core/src/main/java/org/apache/hop/core/vfs/plugin/IVfs.java 
b/core/src/main/java/org/apache/hop/core/vfs/plugin/IVfs.java
index a6ccf71680..b44fdc1bc7 100644
--- a/core/src/main/java/org/apache/hop/core/vfs/plugin/IVfs.java
+++ b/core/src/main/java/org/apache/hop/core/vfs/plugin/IVfs.java
@@ -20,6 +20,7 @@ package org.apache.hop.core.vfs.plugin;
 import java.util.Map;
 import org.apache.commons.vfs2.provider.FileProvider;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 
 public interface IVfs {
   /**
@@ -47,4 +48,22 @@ public interface IVfs {
    * @return a provider per named connection, empty or null if there are none
    */
   Map<String, FileProvider> getProviders(IVariables variables);
+
+  /**
+   * The providers of the named connections in {@code metadataProvider}, keyed 
by connection name.
+   *
+   * <p>An execution does not always run against the metadata the variables 
point at: a pipeline
+   * exported to a Hop Server carries its own, bundled in the export. 
Implement this method to read
+   * the connections from the metadata handed in; the default keeps the old 
behaviour of deriving
+   * the metadata from the variables, so a plugin that does not implement it 
still works.
+   *
+   * @param variables the variables the connections resolve their settings with
+   * @param metadataProvider the metadata holding the connections, or null to 
derive it from the
+   *     variables
+   * @return a provider per named connection, empty or null if there are none
+   */
+  default Map<String, FileProvider> getProviders(
+      IVariables variables, IHopMetadataProvider metadataProvider) {
+    return getProviders(variables);
+  }
 }
diff --git 
a/core/src/main/java/org/apache/hop/metadata/util/HopMetadataInstance.java 
b/core/src/main/java/org/apache/hop/metadata/util/HopMetadataInstance.java
index 4a24331048..9c28df442d 100644
--- a/core/src/main/java/org/apache/hop/metadata/util/HopMetadataInstance.java
+++ b/core/src/main/java/org/apache/hop/metadata/util/HopMetadataInstance.java
@@ -18,12 +18,22 @@
 
 package org.apache.hop.metadata.util;
 
+import org.apache.hop.core.scope.IHopScope;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 
+/**
+ * The metadata Hop is currently working with, for the code that has nothing 
else to go on.
+ *
+ * <p>How far "currently" reaches is a property of the runtime, not of this 
class. In a client or a
+ * server it is the whole process: one project, or one server configuration, 
set once at startup. In
+ * Hop Web it is the session - every user has their own project open in the 
same JVM, and a process
+ * wide answer would hand one user's metadata to another. A runtime that needs 
a different reach
+ * installs its own scope with {@link #setScope(IHopScope)} before anything 
reads this.
+ */
 public class HopMetadataInstance {
   private static HopMetadataInstance instance;
 
-  private MultiMetadataProvider metadataProvider;
+  private IHopScope<MultiMetadataProvider> scope = IHopScope.process();
 
   private HopMetadataInstance() {
     // Nothing to do here.
@@ -36,11 +46,22 @@ public class HopMetadataInstance {
     return instance;
   }
 
+  /**
+   * Change how far "current" reaches. Call this once at startup, before 
anything reads the metadata
+   * provider: Hop Web installs a per session scope here, everything else 
leaves the process wide
+   * default in place.
+   *
+   * @param scope the scope to hold the metadata provider in, null restores 
the process wide default
+   */
+  public static void setScope(IHopScope<MultiMetadataProvider> scope) {
+    getInstance().scope = scope == null ? IHopScope.process() : scope;
+  }
+
   public static void setMetadataProvider(MultiMetadataProvider 
metadataProvider) {
-    getInstance().metadataProvider = metadataProvider;
+    getInstance().scope.set(metadataProvider);
   }
 
   public static MultiMetadataProvider getMetadataProvider() {
-    return getInstance().metadataProvider;
+    return getInstance().scope.get();
   }
 }
diff --git 
a/core/src/test/java/org/apache/hop/core/variables/VariablesMetadataAnchorTest.java
 
b/core/src/test/java/org/apache/hop/core/variables/VariablesMetadataAnchorTest.java
new file mode 100644
index 0000000000..3b0d1fcea0
--- /dev/null
+++ 
b/core/src/test/java/org/apache/hop/core/variables/VariablesMetadataAnchorTest.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.mock;
+
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Hop GUI keeps its project's metadata reachable from its variables by 
putting it behind them,
+ * rather than in them, so that handing the GUI a fresh set of variables - 
which loading a project
+ * does - cannot quietly lose it. This is the property that makes that safe.
+ */
+class VariablesMetadataAnchorTest {
+
+  @Test
+  @DisplayName("An anchor behind a variable space is found, and does not touch 
variable lookup")
+  void anchorIsReachableAndInert() {
+    IHopMetadataProvider provider = mock(IHopMetadataProvider.class);
+    IVariables anchor =
+        new Variables() {
+          @Override
+          public IHopMetadataProvider getMetadataProvider() {
+            return provider;
+          }
+        };
+
+    IVariables freshSpace = Variables.getADefaultVariableSpace();
+    freshSpace.setParentVariables(anchor);
+    freshSpace.setVariable("SOME_VARIABLE", "its own value");
+
+    assertSame(
+        provider,
+        freshSpace.findExecutionMetadataProvider(),
+        "The metadata behind the space has to be reachable from it");
+    assertEquals(
+        "its own value",
+        freshSpace.getVariable("SOME_VARIABLE"),
+        "The anchor must not interfere with looking a variable up");
+  }
+}
diff --git 
a/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceScopingTest.java 
b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceScopingTest.java
new file mode 100644
index 0000000000..7748cd648e
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceScopingTest.java
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import org.apache.commons.vfs2.impl.DefaultFileSystemManager;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.scope.IHopScope;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataInstance;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Which context gets a VFS namespace of its own, and which is already served 
by the process wide
+ * file system manager. The answer has to hold in a client, on a server 
running exports for several
+ * people, and in Hop Web where one JVM serves many sessions at once.
+ */
+class HopVfsNamespaceScopingTest {
+
+  @BeforeAll
+  static void initLogging() {
+    // Creating a namespace logs which connections it registered.
+    HopLogStore.init();
+  }
+
+  @BeforeEach
+  @AfterEach
+  void clearState() {
+    HopVfsNamespaces.reset();
+    HopVfsNamespaces.setScope(null);
+    HopMetadataInstance.setScope(null);
+    HopMetadataInstance.setMetadataProvider(null);
+    HopVfs.setBootstrapVariables(null);
+    HopVfs.reset();
+  }
+
+  /**
+   * What a runtime serving several tenants at once installs at startup: both 
the metadata and the
+   * VFS namespace answer per session rather than per process.
+   */
+  private IHopScope<MultiMetadataProvider> installSessionScopes() {
+    IHopScope<MultiMetadataProvider> perSession = IHopScope.process();
+    HopMetadataInstance.setScope(perSession);
+    HopVfsNamespaces.setScope(IHopScope.process());
+    return perSession;
+  }
+
+  /** Build the process wide manager the way loading a project does, from this 
metadata. */
+  private void bootstrapProcessManagerFrom(MultiMetadataProvider provider) {
+    HopMetadataInstance.setMetadataProvider(provider);
+    HopVfs.setBootstrapVariables(Variables.getADefaultVariableSpace());
+    HopVfs.getFileSystemManager();
+  }
+
+  @Test
+  @DisplayName("The metadata behind the process manager needs no namespace of 
its own")
+  void theProcessMetadataUsesTheProcessManager() {
+    MultiMetadataProvider projectMetadata = mock(MultiMetadataProvider.class);
+    bootstrapProcessManagerFrom(projectMetadata);
+
+    assertNull(
+        HopVfsNamespaces.acquire(new Variables(), projectMetadata, "local 
run"),
+        "A run against the metadata the manager was built from should just use 
it");
+  }
+
+  @Test
+  @DisplayName("Metadata of its own - an export on a server - gets a namespace 
of its own")
+  void otherMetadataGetsItsOwnNamespace() {
+    MultiMetadataProvider serverMetadata = mock(MultiMetadataProvider.class);
+    bootstrapProcessManagerFrom(serverMetadata);
+
+    MultiMetadataProvider exportedMetadata = mock(MultiMetadataProvider.class);
+    HopVfsNamespace namespace =
+        HopVfsNamespaces.acquire(new Variables(), exportedMetadata, "exported 
workflow");
+    try {
+      assertNotNull(namespace, "An export carries its own connections and 
needs its own namespace");
+      assertNotSame(
+          HopVfs.getFileSystemManager(),
+          namespace.getFileSystemManager(),
+          "The export must not be resolving files on the shared manager");
+    } finally {
+      HopVfsNamespaces.release(exportedMetadata);
+    }
+  }
+
+  @Test
+  @DisplayName("Two Hop Web sessions on their own projects do not share a 
namespace")
+  void sessionsDoNotShareANamespace() {
+    // Hop Web gives every session its own metadata, and installs a session 
scope so that
+    // HopMetadataInstance answers per session rather than per process.
+    MultiMetadataProvider sessionA = mock(MultiMetadataProvider.class);
+    MultiMetadataProvider sessionB = mock(MultiMetadataProvider.class);
+
+    IHopScope<MultiMetadataProvider> perSession = installSessionScopes();
+
+    // Session A opens first, so the process wide manager ends up holding its 
connections.
+    bootstrapProcessManagerFrom(sessionA);
+
+    // Session B opens its own project. Its metadata becomes "current" for 
that session, which must
+    // not make it look like the metadata the shared manager was built from.
+    perSession.set(sessionB);
+
+    HopVfsNamespace namespaceB = HopVfsNamespaces.acquire(new Variables(), 
sessionB, "session B");
+    HopVfsNamespace namespaceA = HopVfsNamespaces.acquire(new Variables(), 
sessionA, "session A");
+    try {
+      assertNotNull(
+          namespaceB, "Session B must not resolve files through the 
connections of session A");
+      assertNotNull(
+          namespaceA,
+          "With tenants sharing the JVM nobody falls back to the process 
manager: they would share"
+              + " it, and one of them rebuilding its connections would take 
the other down");
+      assertNotSame(namespaceA, namespaceB);
+      assertNotSame(HopVfs.getFileSystemManager(), 
namespaceB.getFileSystemManager());
+    } finally {
+      HopVfsNamespaces.release(sessionB);
+      HopVfsNamespaces.release(sessionA);
+    }
+  }
+
+  @Test
+  @DisplayName("Contexts sharing metadata share one namespace, and it closes 
with the last of them")
+  void sharedMetadataSharesOneNamespace() {
+    bootstrapProcessManagerFrom(mock(MultiMetadataProvider.class));
+    MultiMetadataProvider shared = mock(MultiMetadataProvider.class);
+
+    HopVfsNamespace first = HopVfsNamespaces.acquire(new Variables(), shared, 
"workflow");
+    HopVfsNamespace second = HopVfsNamespaces.acquire(new Variables(), shared, 
"its pipeline");
+
+    assertSame(first, second, "A pipeline inside a workflow resolves in the 
same namespace");
+    assertSame(1, HopVfsNamespaces.size());
+
+    HopVfsNamespaces.release(shared);
+    assertSame(1, HopVfsNamespaces.size(), "The workflow is still using it");
+
+    HopVfsNamespaces.release(shared);
+    assertSame(0, HopVfsNamespaces.size(), "Nothing left using it, so it 
closes");
+  }
+
+  @Test
+  @DisplayName("Refreshing one tenant's connections leaves the others alone")
+  void refreshOnlyTouchesTheCallersNamespace() throws Exception {
+    MultiMetadataProvider sessionA = mock(MultiMetadataProvider.class);
+    MultiMetadataProvider sessionB = mock(MultiMetadataProvider.class);
+
+    installSessionScopes();
+    bootstrapProcessManagerFrom(sessionA);
+
+    HopVfsNamespace namespaceA = HopVfsNamespaces.acquire(new Variables(), 
sessionA, "session A");
+    HopVfsNamespace namespaceB = HopVfsNamespaces.acquire(new Variables(), 
sessionB, "session B");
+    try {
+      DefaultFileSystemManager managerOfA = namespaceA.getFileSystemManager();
+      DefaultFileSystemManager managerOfB = namespaceB.getFileSystemManager();
+
+      // Session B saves a VFS connection. Its own manager is rebuilt to pick 
the change up...
+      HopVfsNamespace previous = HopVfsNamespaces.bindThread(namespaceB);
+      try {
+        assertTrue(HopVfsNamespaces.refresh(new Variables()));
+      } finally {
+        HopVfsNamespaces.restoreThread(previous);
+      }
+
+      assertNotSame(managerOfB, namespaceB.getFileSystemManager(), "B should 
have read them again");
+      assertSame(
+          managerOfA,
+          namespaceA.getFileSystemManager(),
+          "Session A was in the middle of something and must not have lost its 
file system");
+    } finally {
+      HopVfsNamespaces.release(sessionA);
+      HopVfsNamespaces.release(sessionB);
+    }
+  }
+
+  @Test
+  @DisplayName("A scope that answers per session never falls back to what a 
pooled thread left")
+  void aSessionNeverInheritsAnotherSessionsNamespace() throws Exception {
+    // Request threads are pooled: the same thread serves one session and then 
another. A session
+    // with nothing bound has to get nothing, not whatever the previous one 
left on that thread.
+    HopVfsNamespace ofSessionA = new HopVfsNamespace("session A");
+
+    Object[] sessionOnThisThread = {"A"};
+    java.util.Map<Object, HopVfsNamespace> bySession = new 
java.util.HashMap<>();
+
+    HopVfsNamespaces.setScope(
+        new IHopScope<HopVfsNamespace>() {
+          @Override
+          public HopVfsNamespace get() {
+            return bySession.get(sessionOnThisThread[0]);
+          }
+
+          @Override
+          public void set(HopVfsNamespace value) {
+            bySession.put(sessionOnThisThread[0], value);
+          }
+
+          @Override
+          public void remove() {
+            bySession.remove(sessionOnThisThread[0]);
+          }
+        });
+    try {
+      HopVfsNamespaces.bindThread(ofSessionA);
+      assertSame(ofSessionA, HopVfsNamespaces.getCurrent());
+
+      // The same thread now serves a session that has opened nothing yet.
+      sessionOnThisThread[0] = "B";
+      assertNull(
+          HopVfsNamespaces.getCurrent(), "Session B was handed the VFS 
namespace of session A");
+    } finally {
+      HopVfsNamespaces.setScope(null);
+    }
+  }
+}
diff --git 
a/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceThreadTest.java 
b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceThreadTest.java
new file mode 100644
index 0000000000..bb11673bb9
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceThreadTest.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.mock;
+
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.scope.IHopScope;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Most call sites resolve a file without any variables in hand, so the 
namespace of the running
+ * execution has to reach them through the thread. These are the properties 
that makes that safe:
+ * executions nest, and they hand work to threads they start themselves.
+ */
+class HopVfsNamespaceThreadTest {
+
+  @AfterEach
+  void tearDown() {
+    HopVfsNamespaces.reset();
+  }
+
+  private HopVfsNamespace namespace(String description) throws HopException {
+    // Bypass the registry: these tests are about the thread binding, not 
about the metadata key.
+    return new HopVfsNamespace(description);
+  }
+
+  @Test
+  @DisplayName("A nested execution puts its parent's namespace back on the way 
out")
+  void nestedBindingRestoresTheParent() throws Exception {
+    HopVfsNamespace outer = namespace("outer");
+    HopVfsNamespace inner = namespace("inner");
+
+    HopVfsNamespace beforeOuter = HopVfsNamespaces.bindThread(outer);
+    try {
+      assertSame(outer, HopVfsNamespaces.getCurrent());
+
+      HopVfsNamespace beforeInner = HopVfsNamespaces.bindThread(inner);
+      try {
+        assertSame(inner, HopVfsNamespaces.getCurrent());
+      } finally {
+        HopVfsNamespaces.restoreThread(beforeInner);
+      }
+
+      assertSame(
+          outer,
+          HopVfsNamespaces.getCurrent(),
+          "The nested execution left its parent resolving files in the wrong 
namespace");
+    } finally {
+      HopVfsNamespaces.restoreThread(beforeOuter);
+    }
+    assertNull(HopVfsNamespaces.getCurrent());
+  }
+
+  @Test
+  @DisplayName("An execution without a namespace of its own does not drop its 
parent's")
+  void bindingNothingKeepsTheParentBinding() throws Exception {
+    HopVfsNamespace outer = namespace("outer");
+
+    HopVfsNamespace beforeOuter = HopVfsNamespaces.bindThread(outer);
+    try {
+      // An execution running against the metadata of the process gets null 
from acquire().
+      HopVfsNamespace beforeInner = HopVfsNamespaces.bindThread(null);
+      HopVfsNamespaces.restoreThread(beforeInner);
+
+      assertSame(outer, HopVfsNamespaces.getCurrent());
+    } finally {
+      HopVfsNamespaces.restoreThread(beforeOuter);
+    }
+  }
+
+  @Test
+  @DisplayName("Threads an execution starts inherit its namespace")
+  void startedThreadsInheritTheNamespace() throws Exception {
+    HopVfsNamespace ns = namespace("pipeline");
+
+    HopVfsNamespace before = HopVfsNamespaces.bindThread(ns);
+    HopVfsNamespace[] seenByChild = new HopVfsNamespace[1];
+    try {
+      // This is how Pipeline and Workflow hand work to their transforms and 
actions.
+      Thread child = new Thread(() -> seenByChild[0] = 
HopVfsNamespaces.getCurrent());
+      child.start();
+      child.join();
+    } finally {
+      HopVfsNamespaces.restoreThread(before);
+    }
+
+    assertSame(ns, seenByChild[0], "A transform thread cannot see the 
namespace of its pipeline");
+  }
+
+  @Test
+  @DisplayName("Unbinding the starter does not disturb threads already 
running")
+  void alreadyStartedThreadsKeepTheirNamespace() throws Exception {
+    HopVfsNamespace ns = namespace("pipeline");
+
+    HopVfsNamespace before = HopVfsNamespaces.bindThread(ns);
+    HopVfsNamespace[] seenByChild = new HopVfsNamespace[1];
+    Thread child = new Thread(() -> seenByChild[0] = 
HopVfsNamespaces.getCurrent());
+    child.start();
+    // The server prepares and starts a pipeline on two different request 
threads, and each hands
+    // the thread back to its pool afterwards.
+    HopVfsNamespaces.restoreThread(before);
+    child.join();
+
+    assertSame(ns, seenByChild[0]);
+    assertNull(HopVfsNamespaces.getCurrent());
+  }
+
+  @Test
+  @DisplayName("Resolving with variables that know nothing falls back to the 
thread")
+  void variablesWithoutMetadataFallBackToTheThread() throws Exception {
+    HopVfsNamespace ns = namespace("pipeline");
+    IHopMetadataProvider unrelated = mock(IHopMetadataProvider.class);
+    assertNull(unrelated.getDescription());
+
+    HopVfsNamespace before = HopVfsNamespaces.bindThread(ns);
+    try {
+      assertSame(ns, HopVfsNamespaces.resolve(new Variables()));
+      assertSame(ns, HopVfsNamespaces.resolve(null));
+    } finally {
+      HopVfsNamespaces.restoreThread(before);
+    }
+  }
+
+  @Test
+  @DisplayName("A replacement scope decides what current means, thread or not")
+  void aReplacementScopeTakesOver() throws Exception {
+    // Hop Web serves many people from one JVM, and the thread handling a 
request says nothing
+    // about whose project it belongs to. It binds a per session scope here 
instead.
+    HopVfsNamespace sessionNamespace = namespace("session");
+    HopVfsNamespace[] held = new HopVfsNamespace[1];
+    try {
+      HopVfsNamespaces.setScope(
+          new IHopScope<HopVfsNamespace>() {
+            @Override
+            public HopVfsNamespace get() {
+              return held[0];
+            }
+
+            @Override
+            public void set(HopVfsNamespace namespace) {
+              held[0] = namespace;
+            }
+
+            @Override
+            public void remove() {
+              held[0] = null;
+            }
+          });
+
+      HopVfsNamespaces.bindThread(sessionNamespace);
+      assertSame(sessionNamespace, HopVfsNamespaces.getCurrent());
+
+      // Not the thread's: a brand new thread that inherited nothing still 
sees it.
+      HopVfsNamespace[] seenElsewhere = new HopVfsNamespace[1];
+      Thread other = new Thread(() -> seenElsewhere[0] = 
HopVfsNamespaces.getCurrent());
+      other.start();
+      other.join();
+      assertSame(sessionNamespace, seenElsewhere[0]);
+    } finally {
+      HopVfsNamespaces.setScope(null);
+    }
+
+    assertNull(HopVfsNamespaces.getCurrent(), "The per thread default should 
be back");
+  }
+}
diff --git a/docker/integration-tests/integration-tests-hop_server.yaml 
b/docker/integration-tests/integration-tests-hop_server.yaml
index a102fe58cb..786326efe6 100644
--- a/docker/integration-tests/integration-tests-hop_server.yaml
+++ b/docker/integration-tests/integration-tests-hop_server.yaml
@@ -23,11 +23,19 @@ services:
     depends_on:
       hop_server:
         condition: service_healthy
+      minio:
+        condition: service_healthy
     links:
       - hop_server
+      - minio
 
   hop_server:
     image: apache/hop:Development
+    depends_on:
+      minio:
+        condition: service_healthy
+    links:
+      - minio
     build:
       context: ../../.
       dockerfile: docker/Dockerfile
@@ -49,4 +57,24 @@ services:
       interval: 20s
       timeout: 10s
       retries: 6
-      start_period: 120s
\ No newline at end of file
+      start_period: 120s
+
+  # Storage behind the "export-only-storage" VFS connection of main-0014. That 
connection lives in
+  # the client project's metadata-client-only folder, so it only ever reaches 
the server inside the
+  # export ZIP - which is what the test is about.
+  minio:
+    image: minio/minio
+    hostname: minio
+    environment:
+      MINIO_REGION: "local"
+      MINIO_REGION_NAME: "local"
+      MINIO_SCHEME: "http"
+      MINIO_ROOT_USER: "ApacheHopAccessKey"
+      MINIO_ROOT_PASSWORD: "ApacheHopSecretKey"
+    command: server /data --console-address ":9001"
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live";]
+      interval: 10s
+      timeout: 10s
+      retries: 12
+      start_period: 20s
diff --git a/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java 
b/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
index c21372d6d1..0306757303 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
@@ -90,6 +90,8 @@ import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
 import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.core.vfs.HopVfsNamespace;
+import org.apache.hop.core.vfs.HopVfsNamespaces;
 import org.apache.hop.execution.sampler.IExecutionDataSampler;
 import org.apache.hop.execution.sampler.IExecutionDataSamplerStore;
 import org.apache.hop.i18n.BaseMessages;
@@ -563,8 +565,44 @@ public abstract class Pipeline
    *
    * @throws HopException in case the pipeline could not be prepared 
(initialized)
    */
+  /** The VFS namespace of this execution, when it runs against metadata of 
its own. */
+  private HopVfsNamespace vfsNamespace;
+
+  /** The metadata it was taken for. Kept so it is let go of by the same key 
it was taken with. */
+  private IHopMetadataProvider vfsNamespaceProvider;
+
   @Override
   public void prepareExecution() throws HopException {
+    // A pipeline carrying its own metadata - an export running on a Hop 
Server - resolves its
+    // named VFS connections in its own namespace, not in the one the server 
was started with.
+    vfsNamespaceProvider = getMetadataProvider();
+    vfsNamespace =
+        HopVfsNamespaces.acquire(this, vfsNamespaceProvider, "pipeline " + 
pipelineMeta.getName());
+    HopVfsNamespace previous = HopVfsNamespaces.bindThread(vfsNamespace);
+    boolean prepared = false;
+    try {
+      prepareExecutionInternal();
+      prepared = true;
+    } finally {
+      HopVfsNamespaces.restoreThread(previous);
+      if (!prepared) {
+        // Preparation failed, so nothing will ever finish this pipeline and 
let the namespace go.
+        // On a server that would leak a file system manager for every export 
that fails to start.
+        releaseVfsNamespace();
+      }
+    }
+  }
+
+  /** Let go of the VFS namespace of this execution, once and by the key it 
was taken with. */
+  private void releaseVfsNamespace() {
+    if (vfsNamespace != null) {
+      HopVfsNamespaces.release(vfsNamespaceProvider);
+      vfsNamespace = null;
+      vfsNamespaceProvider = null;
+    }
+  }
+
+  private void prepareExecutionInternal() throws HopException {
     setPreparing(true);
     executionStartDate = new Date();
     setRunning(false);
@@ -1251,6 +1289,15 @@ public abstract class Pipeline
    */
   @Override
   public void startThreads() throws HopException {
+    HopVfsNamespace previous = HopVfsNamespaces.bindThread(vfsNamespace);
+    try {
+      startThreadsInternal();
+    } finally {
+      HopVfsNamespaces.restoreThread(previous);
+    }
+  }
+
+  private void startThreadsInternal() throws HopException {
     // Now prepare to start all the threads...
     //
     nrOfFinishedTransforms = 0;
@@ -1498,6 +1545,10 @@ public abstract class Pipeline
     //
     ExtensionPointHandler.callExtensionPoint(
         log, this, HopExtensionPoint.PipelineCompleted.id, this);
+
+    // Only now: everything above can still touch files of this namespace, and 
closing it
+    // invalidates every file object resolved through it - the result files 
carry those.
+    releaseVfsNamespace();
   }
 
   public void pipelineCompleted() throws HopException {
diff --git a/engine/src/main/java/org/apache/hop/workflow/Workflow.java 
b/engine/src/main/java/org/apache/hop/workflow/Workflow.java
index c19c9e4dd5..07282b78a3 100644
--- a/engine/src/main/java/org/apache/hop/workflow/Workflow.java
+++ b/engine/src/main/java/org/apache/hop/workflow/Workflow.java
@@ -65,6 +65,8 @@ import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
 import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.core.vfs.HopVfsNamespace;
+import org.apache.hop.core.vfs.HopVfsNamespaces;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.pipeline.IExecutionFinishedListener;
@@ -287,6 +289,15 @@ public abstract class Workflow extends Variables
     return r;
   }
 
+  /** The VFS namespace of this execution, when it runs against metadata of 
its own. */
+  private HopVfsNamespace vfsNamespace;
+
+  /** What was bound to the thread before this execution, to put back when it 
finishes. */
+  private HopVfsNamespace previousVfsNamespace;
+
+  /** The metadata it was taken for. Kept so it is let go of by the same key 
it was taken with. */
+  private IHopMetadataProvider vfsNamespaceProvider;
+
   @Override
   public Result startExecution() {
 
@@ -308,6 +319,14 @@ public abstract class Workflow extends Variables
 
       setInternalHopVariables();
 
+      // A workflow carrying its own metadata - an export running on a Hop 
Server - resolves its
+      // named VFS connections in its own namespace, not in the one the server 
was started with.
+      vfsNamespaceProvider = getMetadataProvider();
+      vfsNamespace =
+          HopVfsNamespaces.acquire(
+              this, vfsNamespaceProvider, "workflow " + 
workflowMeta.getName());
+      previousVfsNamespace = HopVfsNamespaces.bindThread(vfsNamespace);
+
       // Run the workflow
       //
       fireExecutionStartedListeners();
@@ -349,6 +368,15 @@ public abstract class Workflow extends Variables
             BaseMessages.getString(PKG, "Workflow.Log.ErrorExecWorkflow", 
e.getMessage()), e);
 
         emergencyWriteWorkflowTracker(result);
+      } finally {
+        // Only now: everything above can still touch files of this namespace, 
and closing it
+        // invalidates every file object resolved through it.
+        HopVfsNamespaces.restoreThread(previousVfsNamespace);
+        if (vfsNamespace != null) {
+          HopVfsNamespaces.release(vfsNamespaceProvider);
+          vfsNamespace = null;
+          vfsNamespaceProvider = null;
+        }
       }
     }
 
diff --git 
a/engine/src/test/java/org/apache/hop/pipeline/PipelineVfsNamespaceTest.java 
b/engine/src/test/java/org/apache/hop/pipeline/PipelineVfsNamespaceTest.java
new file mode 100644
index 0000000000..e45b31d6e1
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/pipeline/PipelineVfsNamespaceTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.vfs.HopVfsNamespaces;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A pipeline running against metadata of its own - an export on a Hop Server 
- takes a VFS
+ * namespace to resolve its named connections in, and has to let go of it 
again. Nothing else will:
+ * the namespace holds a file system manager, and one that is never released 
is never closed.
+ */
+class PipelineVfsNamespaceTest {
+
+  @BeforeAll
+  static void setUpBeforeClass() throws HopException {
+    HopEnvironment.init();
+  }
+
+  @AfterEach
+  void clearNamespaces() {
+    HopVfsNamespaces.reset();
+  }
+
+  @Test
+  @DisplayName("A pipeline that fails to prepare does not leave its VFS 
namespace behind")
+  void aFailedPreparationLetsGoOfTheNamespace() {
+    IHopMetadataProvider ownMetadata = mock(IHopMetadataProvider.class);
+
+    Pipeline pipeline = spy(new LocalPipelineEngine(new PipelineMeta()));
+    pipeline.setLogChannel(mock(ILogChannel.class));
+    pipeline.setMetadataProvider(ownMetadata);
+
+    // Anything at all going wrong while preparing: a transform that will not 
initialise, a run
+    // configuration that cannot be loaded, a file that is not there.
+    doThrow(new 
HopRuntimeExceptionForTest()).when(pipeline).setPreparing(anyBoolean());
+
+    assertThrows(HopRuntimeExceptionForTest.class, pipeline::prepareExecution);
+
+    assertEquals(
+        0,
+        HopVfsNamespaces.size(),
+        "The pipeline never runs and never finishes, so nothing else would 
ever close this");
+  }
+
+  /** Distinct type so the assertion cannot pass on some other failure. */
+  private static class HopRuntimeExceptionForTest extends RuntimeException {}
+}
diff --git a/integration-tests/hop_server/0014-write-to-export-only-vfs.hpl 
b/integration-tests/hop_server/0014-write-to-export-only-vfs.hpl
new file mode 100644
index 0000000000..5e4f307396
--- /dev/null
+++ b/integration-tests/hop_server/0014-write-to-export-only-vfs.hpl
@@ -0,0 +1,153 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+  <info>
+    <name>0014-write-to-export-only-vfs</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description>Runs on the remote Hop Server and writes a file through the 
"export-only-storage"
+      named VFS connection. That connection only exists in the client project's
+      metadata-client-only folder, so it reaches the server exclusively inside 
the export ZIP. If
+      the server does not register the VFS connections it was sent, the scheme 
is unknown, the path
+      is treated as a relative local file and nothing lands in the object 
store (#8106).</description>
+    <extended_description/>
+    <pipeline_version/>
+    <pipeline_type>Normal</pipeline_type>
+    <parameters>
+    </parameters>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2024/01/01 00:00:00.000</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2024/01/01 00:00:00.000</modified_date>
+    <key_for_session_key/>
+    <is_key_private>N</is_key_private>
+  </info>
+  <notepads>
+  </notepads>
+  <order>
+    <hop>
+      <from>Generate rows</from>
+      <to>Write to export-only-storage</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <transform>
+    <name>Generate rows</name>
+    <type>RowGenerator</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <fields>
+      <field>
+        <name>marker</name>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif>written-through-exported-vfs-connection</nullif>
+        <length>-1</length>
+        <precision>-1</precision>
+        <set_empty_string>N</set_empty_string>
+      </field>
+    </fields>
+    <limit>1</limit>
+    <never_ending>N</never_ending>
+    <interval_in_ms>5000</interval_in_ms>
+    <row_time_field>now</row_time_field>
+    <last_time_field>FiveSecondsAgo</last_time_field>
+    <attributes/>
+    <GUI>
+      <xloc>144</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Write to export-only-storage</name>
+    <type>TextFileOutput</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <separator>;</separator>
+    <enclosure>"</enclosure>
+    <enclosure_forced>N</enclosure_forced>
+    <enclosure_fix_disabled>N</enclosure_fix_disabled>
+    <header>N</header>
+    <footer>N</footer>
+    <format>DOS</format>
+    <compression>None</compression>
+    <encoding>UTF-8</encoding>
+    <endedLine/>
+    <fileNameInField>N</fileNameInField>
+    <fileNameField/>
+    <create_parent_folder>Y</create_parent_folder>
+    <file>
+      <name>export-only-storage:///demo/marker-8106</name>
+      <servlet_output>N</servlet_output>
+      <do_not_open_new_file_init>N</do_not_open_new_file_init>
+      <extention>txt</extention>
+      <append>N</append>
+      <split>N</split>
+      <haspartno>N</haspartno>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <add_to_result_filenames>N</add_to_result_filenames>
+      <pad>N</pad>
+      <fast_dump>N</fast_dump>
+      <splitevery/>
+    </file>
+    <fields>
+      <field>
+        <name>marker</name>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <trim_type>none</trim_type>
+        <length>-1</length>
+        <precision>-1</precision>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>384</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git a/integration-tests/hop_server/0014-write-to-export-only-vfs.hwf 
b/integration-tests/hop_server/0014-write-to-export-only-vfs.hwf
new file mode 100644
index 0000000000..cc11c71780
--- /dev/null
+++ b/integration-tests/hop_server/0014-write-to-export-only-vfs.hwf
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>0014-write-to-export-only-vfs</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Runs entirely on the remote Hop Server, shipped there by the 
"remote" run
+    configuration of the parent workflow. All it does is run the pipeline that 
writes through the
+    "export-only-storage" named VFS connection; the parent verifies the result 
from the client
+    side, where that connection is known to work.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2024/01/01 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2024/01/01 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>50</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Write through the exported connection</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <filename>${PROJECT_HOME}/0014-write-to-export-only-vfs.hpl</filename>
+      <params_from_previous>N</params_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <run_configuration>local</run_configuration>
+      <wait_until_finished>Y</wait_until_finished>
+      <create_parent_folder>N</create_parent_folder>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <set_append_logfile>N</set_append_logfile>
+      <parallel>N</parallel>
+      <xloc>250</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>Write through the exported connection</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git 
a/integration-tests/hop_server/main-0014-test-export-only-vfs-connection.hwf 
b/integration-tests/hop_server/main-0014-test-export-only-vfs-connection.hwf
new file mode 100644
index 0000000000..a270df2624
--- /dev/null
+++ b/integration-tests/hop_server/main-0014-test-export-only-vfs-connection.hwf
@@ -0,0 +1,159 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0014-test-export-only-vfs-connection</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Regression test for issue #8106: a named VFS connection carried 
by an export must be
+    usable by the pipeline running on the remote Hop Server.
+
+    The "export-only-storage" Minio connection lives in the client project's 
metadata-client-only
+    folder. The server registers ${PROJECT_HOME}/metadata as its own metadata 
folder, so it never
+    sees that connection except inside the export ZIP. The inner workflow runs 
remotely and writes
+    a file through it; the check below runs back on the client, where the 
connection is known to
+    resolve, and proves the server really wrote to the object store.
+
+    Before the fix the server has no provider for the "export-only-storage" 
scheme, so the path is
+    treated as a relative local file, nothing reaches Minio and this test 
fails.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2024/01/01 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2024/01/01 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>50</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Cleanup marker in object store</name>
+      <description>Runs on the client, which can reach the same Minio. 
Removing the file first means
+        the check can only succeed on something the server wrote during this 
run, and it keeps the
+        export from bundling an existing file in place of the connection 
reference.</description>
+      <type>DELETE_FILE</type>
+      <attributes/>
+      <filename>export-only-storage:///demo/marker-8106.txt</filename>
+      <fail_if_file_not_exists>N</fail_if_file_not_exists>
+      <parallel>N</parallel>
+      <xloc>220</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Write on the server through the exported connection</name>
+      <description/>
+      <type>WORKFLOW</type>
+      <attributes/>
+      <run_configuration>remote</run_configuration>
+      <filename>${PROJECT_HOME}/0014-write-to-export-only-vfs.hwf</filename>
+      <params_from_previous>N</params_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <wait_until_finished>Y</wait_until_finished>
+      <create_parent_folder>N</create_parent_folder>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <set_append_logfile>N</set_append_logfile>
+      <parallel>N</parallel>
+      <xloc>470</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Verify the server wrote to the object store</name>
+      <description/>
+      <type>FILE_EXISTS</type>
+      <attributes/>
+      <filename>export-only-storage:///demo/marker-8106.txt</filename>
+      <parallel>N</parallel>
+      <xloc>730</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Make sure the bucket exists</name>
+      <description>Runs on the client, through the same connection. The test 
is about the server
+        being able to use a connection it only got from the export, not about 
who creates the
+        bucket, so the folder is prepared here.</description>
+      <type>CREATE_FOLDER</type>
+      <attributes/>
+      <foldername>export-only-storage:///demo</foldername>
+      <parallel>N</parallel>
+      <xloc>350</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>Cleanup marker in object store</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>Cleanup marker in object store</from>
+      <to>Make sure the bucket exists</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>Make sure the bucket exists</from>
+      <to>Write on the server through the exported connection</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>Write on the server through the exported connection</from>
+      <to>Verify the server wrote to the object store</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git 
a/integration-tests/hop_server/metadata-client-only/MinioConnectionDefinition/export-only-storage.json
 
b/integration-tests/hop_server/metadata-client-only/MinioConnectionDefinition/export-only-storage.json
new file mode 100644
index 0000000000..2dc65d223a
--- /dev/null
+++ 
b/integration-tests/hop_server/metadata-client-only/MinioConnectionDefinition/export-only-storage.json
@@ -0,0 +1,12 @@
+{
+  "endPointHostname": "minio",
+  "virtualPath": "",
+  "secretKey": "ApacheHopSecretKey",
+  "accessKey": "ApacheHopAccessKey",
+  "partSize": "5242880",
+  "name": "export-only-storage",
+  "description": "A named VFS connection that exists ONLY in the client 
project's metadata-client-only folder. The Hop Server registers 
${PROJECT_HOME}/metadata as its metadata folder, so this connection can only 
reach the server inside the export ZIP. Used by main-0014 to prove that VFS 
connections carried by an export are usable on the server (issue #8106). Values 
are literals on purpose: the test is about the connection reaching the server, 
not about variable resolution.",
+  "endPointPort": "9000",
+  "endPointSecure": false,
+  "region": "local"
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index c47b7c2347..77ecf6d69a 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -329,8 +329,10 @@ public class ProjectsGuiPlugin {
           HopExtensionPoint.HopGuiProjectAfterEnabled.name(),
           project);
 
-      // Reset VFS filesystem to load additional configurations
-      HopVfs.reset();
+      // The project that just opened has its own VFS connections: take its 
namespace, and let go
+      // of the one of the project we came from. Not a full reset - in Hop Web 
that would empty the
+      // file system manager of every other session as well.
+      hopGui.useVfsNamespaceOfOpenProject();
 
       // Finally, warn about metadata elements in this project which we can't 
load.
       // They're ignored so the project itself opens just fine.
diff --git 
a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/S3VfsPlugin.java 
b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/S3VfsPlugin.java
index a886b8bb70..63d8fb76d7 100644
--- a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/S3VfsPlugin.java
+++ b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/S3VfsPlugin.java
@@ -43,10 +43,18 @@ public class S3VfsPlugin implements IVfs {
 
   @Override
   public Map<String, FileProvider> getProviders(IVariables variables) {
+    return getProviders(variables, null);
+  }
+
+  @Override
+  public Map<String, FileProvider> getProviders(
+      IVariables variables, IHopMetadataProvider executionMetadata) {
     Map<String, FileProvider> providers = new HashMap<>();
     try {
       IHopMetadataProvider metadataProvider =
-          HopMetadataUtil.getStandardHopMetadataProvider(variables);
+          executionMetadata != null
+              ? executionMetadata
+              : HopMetadataUtil.getStandardHopMetadataProvider(variables);
       List<S3Meta> s3MetaList = 
metadataProvider.getSerializer(S3Meta.class).loadAll();
       for (S3Meta s3Meta : s3MetaList) {
         providers.put(s3Meta.getName(), new S3FileProvider(variables, s3Meta));
diff --git 
a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java 
b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
index 3f63f15b0c..f729072d97 100644
--- a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
+++ b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
@@ -37,7 +37,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
     image = "s3.svg",
     category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/s3-connection.html",
-    hopMetadataPropertyType = HopMetadataPropertyType.VFS_S3_CONNECTION)
+    hopMetadataPropertyType = HopMetadataPropertyType.VFS_S3_CONNECTION,
+    classLoaderGroup = "vfs-s3")
 public class S3Meta extends HopMetadataBase implements Serializable, 
IHopMetadata {
 
   @HopMetadataProperty private String description;
diff --git 
a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3MetaEditor.java
 
b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3MetaEditor.java
index b530a18a7e..800cb10973 100644
--- 
a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3MetaEditor.java
+++ 
b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3MetaEditor.java
@@ -576,6 +576,6 @@ public class S3MetaEditor extends MetadataEditor<S3Meta> {
   @Override
   public void save() throws HopException {
     super.save();
-    HopVfs.reset();
+    HopVfs.refresh(hopGui.getVariables());
   }
 }
diff --git 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/AzureVfsPlugin.java 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/AzureVfsPlugin.java
index 04c31950a6..00a943c5af 100644
--- 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/AzureVfsPlugin.java
+++ 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/AzureVfsPlugin.java
@@ -22,6 +22,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.apache.commons.vfs2.provider.FileProvider;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.plugin.IVfs;
 import org.apache.hop.core.vfs.plugin.VfsPlugin;
@@ -43,10 +44,18 @@ public class AzureVfsPlugin implements IVfs {
 
   @Override
   public Map<String, FileProvider> getProviders(IVariables variables) {
+    return getProviders(variables, null);
+  }
+
+  @Override
+  public Map<String, FileProvider> getProviders(
+      IVariables variables, IHopMetadataProvider executionMetadata) {
     Map<String, FileProvider> providers = new HashMap<>();
     try {
       IHopMetadataProvider metadataProvider =
-          HopMetadataUtil.getStandardHopMetadataProvider(variables);
+          executionMetadata != null
+              ? executionMetadata
+              : HopMetadataUtil.getStandardHopMetadataProvider(variables);
       List<AzureMetadataType> azureMetadataTypes =
           metadataProvider.getSerializer(AzureMetadataType.class).loadAll();
       for (AzureMetadataType azureMetadataType : azureMetadataTypes) {
@@ -54,7 +63,7 @@ public class AzureVfsPlugin implements IVfs {
             azureMetadataType.getName(), new AzureFileProvider(variables, 
azureMetadataType));
       }
     } catch (Exception e) {
-      e.printStackTrace();
+      LogChannel.GENERAL.logError("Unable to load the Azure VFS providers", e);
     }
     return providers;
   }
diff --git 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
index e6c82ee407..ae278fe3b4 100644
--- 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
+++ 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
@@ -33,7 +33,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
     image = "ui/images/authentication.svg",
     category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/azure-authentication.html",
-    hopMetadataPropertyType = HopMetadataPropertyType.VFS_AZURE_CONNECTION)
+    hopMetadataPropertyType = HopMetadataPropertyType.VFS_AZURE_CONNECTION,
+    classLoaderGroup = "vfs-azure")
 @Getter
 @Setter
 public class AzureMetadataType extends HopMetadataBase implements 
Serializable, IHopMetadata {
diff --git 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataTypeEditor.java
 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataTypeEditor.java
index f0019bbbc0..46a9b0094a 100644
--- 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataTypeEditor.java
+++ 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataTypeEditor.java
@@ -301,6 +301,6 @@ public class AzureMetadataTypeEditor extends 
MetadataEditor<AzureMetadataType> {
   @Override
   public void save() throws HopException {
     super.save();
-    HopVfs.reset();
+    HopVfs.refresh(hopGui.getVariables());
   }
 }
diff --git 
a/plugins/tech/databricks/src/main/java/org/apache/hop/databricks/metadata/DatabricksConnection.java
 
b/plugins/tech/databricks/src/main/java/org/apache/hop/databricks/metadata/DatabricksConnection.java
index 12418bf055..0836f89991 100644
--- 
a/plugins/tech/databricks/src/main/java/org/apache/hop/databricks/metadata/DatabricksConnection.java
+++ 
b/plugins/tech/databricks/src/main/java/org/apache/hop/databricks/metadata/DatabricksConnection.java
@@ -44,7 +44,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
     description = "i18n::DatabricksConnection.Description",
     image = "databricks-connection.svg",
     category = HopMetadataCategory.CONNECTIONS,
-    documentationUrl = "/metadata-types/databricks-connection.html")
+    documentationUrl = "/metadata-types/databricks-connection.html",
+    classLoaderGroup = "vfs-databricks")
 public class DatabricksConnection extends HopMetadataBase implements 
Serializable, IHopMetadata {
 
   private static final String PARENT = 
DatabricksConnectionEditor.GUI_WIDGETS_PARENT_ID;
diff --git 
a/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/DatabricksVfsPlugin.java
 
b/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/DatabricksVfsPlugin.java
index 2df9328b9d..4987ad5867 100644
--- 
a/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/DatabricksVfsPlugin.java
+++ 
b/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/DatabricksVfsPlugin.java
@@ -55,11 +55,18 @@ public class DatabricksVfsPlugin implements IVfs {
 
   @Override
   public Map<String, FileProvider> getProviders(IVariables variables) {
+    return getProviders(variables, null);
+  }
+
+  @Override
+  public Map<String, FileProvider> getProviders(
+      IVariables variables, IHopMetadataProvider executionMetadata) {
     Map<String, FileProvider> providers = new HashMap<>();
     try {
-      // Prefer the active runtime metadata (GUI project / MainSpark export). 
Standard folders
-      // alone are empty on Databricks (no hop-config metadata tree).
-      IHopMetadataProvider metadataProvider = 
HopMetadataInstance.getMetadataProvider();
+      IHopMetadataProvider metadataProvider = executionMetadata;
+      if (metadataProvider == null) {
+        metadataProvider = HopMetadataInstance.getMetadataProvider();
+      }
       if (metadataProvider == null) {
         metadataProvider = 
HopMetadataUtil.getStandardHopMetadataProvider(variables);
       }
diff --git 
a/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/metadata/DatabricksVfsConnection.java
 
b/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/metadata/DatabricksVfsConnection.java
index 382cc33c98..7815fe572a 100644
--- 
a/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/metadata/DatabricksVfsConnection.java
+++ 
b/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/metadata/DatabricksVfsConnection.java
@@ -46,7 +46,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
     image = "databricks-connection.svg",
     category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/databricks-vfs-connection.html",
-    hopMetadataPropertyType = 
HopMetadataPropertyType.VFS_DATABRICKS_CONNECTION)
+    hopMetadataPropertyType = 
HopMetadataPropertyType.VFS_DATABRICKS_CONNECTION,
+    classLoaderGroup = "vfs-databricks")
 public class DatabricksVfsConnection extends HopMetadataBase implements 
Serializable, IHopMetadata {
 
   private static final String PARENT = 
DatabricksVfsConnectionEditor.GUI_WIDGETS_PARENT_ID;
diff --git 
a/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/FtpVfsPlugin.java 
b/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/FtpVfsPlugin.java
index 6aabd18c11..a51d6cc082 100644
--- a/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/FtpVfsPlugin.java
+++ b/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/FtpVfsPlugin.java
@@ -59,10 +59,18 @@ public class FtpVfsPlugin implements IVfs {
 
   @Override
   public Map<String, FileProvider> getProviders(IVariables variables) {
+    return getProviders(variables, null);
+  }
+
+  @Override
+  public Map<String, FileProvider> getProviders(
+      IVariables variables, IHopMetadataProvider executionMetadata) {
     Map<String, FileProvider> providers = new HashMap<>();
     try {
       IHopMetadataProvider metadataProvider =
-          HopMetadataUtil.getStandardHopMetadataProvider(variables);
+          executionMetadata != null
+              ? executionMetadata
+              : HopMetadataUtil.getStandardHopMetadataProvider(variables);
       List<FtpConnection> connections =
           metadataProvider.getSerializer(FtpConnection.class).loadAll();
       for (FtpConnection connection : connections) {
diff --git 
a/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/metadata/FtpConnection.java
 
b/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/metadata/FtpConnection.java
index b65ad4429b..32dcc07214 100644
--- 
a/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/metadata/FtpConnection.java
+++ 
b/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/metadata/FtpConnection.java
@@ -47,7 +47,8 @@ import org.apache.hop.vfs.ftp.IFtpConnection;
     category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/ftp-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.VFS_FTP_CONNECTION,
-    supportsGlobalReplace = true)
+    supportsGlobalReplace = true,
+    classLoaderGroup = "sftp")
 public class FtpConnection extends HopMetadataBase
     implements Serializable, IHopMetadata, IFtpConnection {
 
diff --git 
a/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/metadata/FtpConnectionEditor.java
 
b/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/metadata/FtpConnectionEditor.java
index de110a9927..f8604f56d6 100644
--- 
a/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/metadata/FtpConnectionEditor.java
+++ 
b/plugins/tech/ftp/src/main/java/org/apache/hop/vfs/ftp/metadata/FtpConnectionEditor.java
@@ -665,6 +665,6 @@ public class FtpConnectionEditor extends 
MetadataEditor<FtpConnection> {
     // The name of a connection is a VFS scheme: re-register the providers so 
the new or changed
     // connection is picked up right away.
     //
-    HopVfs.reset();
+    HopVfs.refresh(hopGui.getVariables());
   }
 }
diff --git 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/GoogleStorageVfsPlugin.java
 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/GoogleStorageVfsPlugin.java
index 47e36aa00a..934fe9026e 100644
--- 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/GoogleStorageVfsPlugin.java
+++ 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/GoogleStorageVfsPlugin.java
@@ -43,10 +43,18 @@ public class GoogleStorageVfsPlugin implements IVfs {
 
   @Override
   public Map<String, FileProvider> getProviders(IVariables variables) {
+    return getProviders(variables, null);
+  }
+
+  @Override
+  public Map<String, FileProvider> getProviders(
+      IVariables variables, IHopMetadataProvider executionMetadata) {
     Map<String, FileProvider> providers = new HashMap<>();
     try {
       IHopMetadataProvider metadataProvider =
-          HopMetadataUtil.getStandardHopMetadataProvider(variables);
+          executionMetadata != null
+              ? executionMetadata
+              : HopMetadataUtil.getStandardHopMetadataProvider(variables);
       List<GoogleStorageMetadataType> googleStorageMetadataTypes =
           
metadataProvider.getSerializer(GoogleStorageMetadataType.class).loadAll();
       for (GoogleStorageMetadataType googleStorageMetadataType : 
googleStorageMetadataTypes) {
diff --git 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
index e60c2e048d..7499d73cd2 100644
--- 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
+++ 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
@@ -33,7 +33,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
     image = "ui/images/authentication.svg",
     category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/google-storage-authentication.html",
-    hopMetadataPropertyType = HopMetadataPropertyType.VFS_GCP_CONNECTION)
+    hopMetadataPropertyType = HopMetadataPropertyType.VFS_GCP_CONNECTION,
+    classLoaderGroup = "vfs-google")
 @Getter
 @Setter
 public class GoogleStorageMetadataType extends HopMetadataBase
diff --git 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataTypeEditor.java
 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataTypeEditor.java
index 1197020e92..dc4bfbb513 100644
--- 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataTypeEditor.java
+++ 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataTypeEditor.java
@@ -189,6 +189,6 @@ public class GoogleStorageMetadataTypeEditor extends 
MetadataEditor<GoogleStorag
   @Override
   public void save() throws HopException {
     super.save();
-    HopVfs.reset();
+    HopVfs.refresh(hopGui.getVariables());
   }
 }
diff --git 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioVfsPlugin.java 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioVfsPlugin.java
index 573437a110..2acb0eacd1 100644
--- 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioVfsPlugin.java
+++ 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioVfsPlugin.java
@@ -22,6 +22,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.apache.commons.vfs2.provider.FileProvider;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.plugin.IVfs;
 import org.apache.hop.core.vfs.plugin.VfsPlugin;
@@ -29,7 +30,7 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.util.HopMetadataUtil;
 import org.apache.hop.vfs.minio.metadata.MinioMeta;
 
-@VfsPlugin(type = "minio", typeDescription = "S3 VFS plugin", classLoaderGroup 
= "vfs-s3")
+@VfsPlugin(type = "minio", typeDescription = "Minio VFS plugin", 
classLoaderGroup = "vfs-minio")
 public class MinioVfsPlugin implements IVfs {
   @Override
   public String[] getUrlSchemes() {
@@ -44,16 +45,26 @@ public class MinioVfsPlugin implements IVfs {
 
   @Override
   public Map<String, FileProvider> getProviders(IVariables variables) {
+    return getProviders(variables, null);
+  }
+
+  @Override
+  public Map<String, FileProvider> getProviders(
+      IVariables variables, IHopMetadataProvider executionMetadata) {
     Map<String, FileProvider> providers = new HashMap<>();
     try {
       IHopMetadataProvider metadataProvider =
-          HopMetadataUtil.getStandardHopMetadataProvider(variables);
+          executionMetadata != null
+              ? executionMetadata
+              : HopMetadataUtil.getStandardHopMetadataProvider(variables);
       List<MinioMeta> minioMetaTypes = 
metadataProvider.getSerializer(MinioMeta.class).loadAll();
       for (MinioMeta minioMeta : minioMetaTypes) {
         providers.put(minioMeta.getName(), new MinioFileProvider(variables, 
minioMeta));
       }
     } catch (Exception e) {
-      // Ignore errors
+      // Never silently: an unreadable connection here means files resolved 
through its scheme
+      // quietly land on the local disk instead of the object store.
+      LogChannel.GENERAL.logError("Unable to load the Minio VFS providers", e);
     }
     return providers;
   }
diff --git 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
index 9be104c995..687be7e45a 100644
--- 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
+++ 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
@@ -39,7 +39,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
     image = "minio.svg",
     category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/minio-connection.html",
-    hopMetadataPropertyType = HopMetadataPropertyType.VFS_MINIO_CONNECTION)
+    hopMetadataPropertyType = HopMetadataPropertyType.VFS_MINIO_CONNECTION,
+    classLoaderGroup = "vfs-minio")
 public class MinioMeta extends HopMetadataBase implements Serializable, 
IHopMetadata {
   private static final String WIDGET_ID_MINIO_DESCRIPTION = 
"10000-minio-description";
   private static final String WIDGET_ID_MINIO_ACCESS_KEY = 
"10050-minio-access-key";
diff --git 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMetaEditor.java
 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMetaEditor.java
index 667254c79f..8807d777e9 100644
--- 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMetaEditor.java
+++ 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMetaEditor.java
@@ -154,6 +154,6 @@ public class MinioMetaEditor extends 
MetadataEditor<MinioMeta> {
   @Override
   public void save() throws HopException {
     super.save();
-    HopVfs.reset();
+    HopVfs.refresh(hopGui.getVariables());
   }
 }
diff --git 
a/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/MinioVfsPluginTest.java
 
b/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/MinioVfsPluginTest.java
index 4544f8f748..df28f92690 100644
--- 
a/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/MinioVfsPluginTest.java
+++ 
b/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/MinioVfsPluginTest.java
@@ -27,6 +27,7 @@ import org.apache.commons.vfs2.provider.FileProvider;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
 import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.apache.hop.vfs.minio.metadata.MinioMeta;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -98,7 +99,27 @@ class MinioVfsPluginTest {
 
     assertNotNull(annotation, "VfsPlugin annotation should not be null");
     assertEquals("minio", annotation.type(), "Plugin type should be 'minio'");
-    assertEquals("S3 VFS plugin", annotation.typeDescription(), "Type 
description should match");
+    assertEquals("Minio VFS plugin", annotation.typeDescription(), "Type 
description should match");
+  }
+
+  @Test
+  void connectionMetadataSharesTheClassLoaderGroupOfThisPlugin() {
+    // The plugin reads MinioMeta objects it did not deserialize itself when 
the connections come
+    // from a resource export.
+    String pluginGroup =
+        plugin
+            .getClass()
+            .getAnnotation(org.apache.hop.core.vfs.plugin.VfsPlugin.class)
+            .classLoaderGroup();
+    String metadataGroup =
+        MinioMeta.class
+            .getAnnotation(org.apache.hop.metadata.api.HopMetadata.class)
+            .classLoaderGroup();
+
+    assertEquals(
+        pluginGroup,
+        metadataGroup,
+        "MinioMeta and the Minio VFS plugin must share a class loader group");
   }
 
   @Test
diff --git 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpVfsPlugin.java 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpVfsPlugin.java
index 43743b42f7..5728cf7006 100644
--- a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpVfsPlugin.java
+++ b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpVfsPlugin.java
@@ -57,10 +57,18 @@ public class SftpVfsPlugin implements IVfs {
 
   @Override
   public Map<String, FileProvider> getProviders(IVariables variables) {
+    return getProviders(variables, null);
+  }
+
+  @Override
+  public Map<String, FileProvider> getProviders(
+      IVariables variables, IHopMetadataProvider executionMetadata) {
     Map<String, FileProvider> providers = new HashMap<>();
     try {
       IHopMetadataProvider metadataProvider =
-          HopMetadataUtil.getStandardHopMetadataProvider(variables);
+          executionMetadata != null
+              ? executionMetadata
+              : HopMetadataUtil.getStandardHopMetadataProvider(variables);
       List<SftpConnection> connections =
           metadataProvider.getSerializer(SftpConnection.class).loadAll();
       for (SftpConnection connection : connections) {
diff --git 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/metadata/SftpConnection.java
 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/metadata/SftpConnection.java
index 58655fdc68..f840c31708 100644
--- 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/metadata/SftpConnection.java
+++ 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/metadata/SftpConnection.java
@@ -44,7 +44,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
     category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/sftp-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.VFS_SFTP_CONNECTION,
-    supportsGlobalReplace = true)
+    supportsGlobalReplace = true,
+    classLoaderGroup = "sftp")
 public class SftpConnection extends HopMetadataBase implements Serializable, 
IHopMetadata {
 
   public static final int DEFAULT_PORT = 22;
diff --git 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/metadata/SftpConnectionEditor.java
 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/metadata/SftpConnectionEditor.java
index 8ff3f1449f..b5d5809dd0 100644
--- 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/metadata/SftpConnectionEditor.java
+++ 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/metadata/SftpConnectionEditor.java
@@ -486,6 +486,6 @@ public class SftpConnectionEditor extends 
MetadataEditor<SftpConnection> {
     // The name of a connection is a VFS scheme: re-register the providers so 
the new or changed
     // connection is picked up right away.
     //
-    HopVfs.reset();
+    HopVfs.refresh(hopGui.getVariables());
   }
 }
diff --git 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/WebDavVfsPlugin.java
 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/WebDavVfsPlugin.java
index 5f6d5a7017..64cf36d9f4 100644
--- 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/WebDavVfsPlugin.java
+++ 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/WebDavVfsPlugin.java
@@ -48,10 +48,18 @@ public class WebDavVfsPlugin implements IVfs {
 
   @Override
   public Map<String, FileProvider> getProviders(IVariables variables) {
+    return getProviders(variables, null);
+  }
+
+  @Override
+  public Map<String, FileProvider> getProviders(
+      IVariables variables, IHopMetadataProvider executionMetadata) {
     Map<String, FileProvider> providers = new HashMap<>();
     try {
       IHopMetadataProvider metadataProvider =
-          HopMetadataUtil.getStandardHopMetadataProvider(variables);
+          executionMetadata != null
+              ? executionMetadata
+              : HopMetadataUtil.getStandardHopMetadataProvider(variables);
       List<WebDavConnection> connections =
           metadataProvider.getSerializer(WebDavConnection.class).loadAll();
       for (WebDavConnection connection : connections) {
diff --git 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
index 69b3461f6d..0c02264eab 100644
--- 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
+++ 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
@@ -41,7 +41,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
     image = "ui/images/authentication.svg",
     category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/webdav-connection.html",
-    hopMetadataPropertyType = HopMetadataPropertyType.VFS_WEBDAV_CONNECTION)
+    hopMetadataPropertyType = HopMetadataPropertyType.VFS_WEBDAV_CONNECTION,
+    classLoaderGroup = "vfs-webdav")
 public class WebDavConnection extends HopMetadataBase implements Serializable, 
IHopMetadata {
 
   @HopMetadataProperty private String description;
diff --git 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnectionEditor.java
 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnectionEditor.java
index 6f6c9d6fbc..3f74627921 100644
--- 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnectionEditor.java
+++ 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnectionEditor.java
@@ -228,6 +228,6 @@ public class WebDavConnectionEditor extends 
MetadataEditor<WebDavConnection> {
   @Override
   public void save() throws HopException {
     super.save();
-    HopVfs.reset();
+    HopVfs.refresh(hopGui.getVariables());
   }
 }
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
index 7dc75d8ef6..eb3ec1c19c 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
@@ -45,8 +45,10 @@ import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.svg.SvgCache;
 import org.apache.hop.core.svg.SvgCacheEntry;
 import org.apache.hop.core.svg.SvgFile;
+import org.apache.hop.core.vfs.HopVfsNamespaces;
 import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.metadata.plugin.MetadataPluginType;
+import org.apache.hop.metadata.util.HopMetadataInstance;
 import org.apache.hop.ui.hopgui.canvas.CanvasRenderServiceHandler;
 import org.apache.hop.ui.hopgui.perspective.HopPerspectivePluginType;
 import org.eclipse.rap.rwt.application.Application;
@@ -79,6 +81,10 @@ public class HopWeb implements ApplicationConfiguration {
   @Override
   public void configure(Application application) {
 
+    HopMetadataInstance.setScope(
+        new RapSessionScope<>(RapSessionScope.MetadataProviderHolder.class));
+    HopVfsNamespaces.setScope(new 
RapSessionScope<>(RapSessionScope.VfsNamespaceHolder.class));
+
     try {
       // Hop initialization is already done here.
       // This means we can simply ask the gui registry for the toolbar images 
to register.
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
index ce87d5e55c..b61e7ecec1 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
@@ -263,7 +263,13 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
               public void beforeDestroy(UISessionEvent event) {
                 try {
                   HopGui hopGui = HopGui.getInstance();
-                  if (hopGui == null || hopGui.auditDelegate == null) {
+                  if (hopGui == null) {
+                    return;
+                  }
+                  // Let go of this session's VFS namespace: it closes once 
nothing is using it,
+                  // and the sessions still running keep theirs.
+                  hopGui.releaseVfsNamespace();
+                  if (hopGui.auditDelegate == null) {
                     return;
                   }
                   if (hopGui.getShell() != null && 
hopGui.getShell().isDisposed()) {
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/RapSessionScope.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/RapSessionScope.java
new file mode 100644
index 0000000000..15ba916158
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/RapSessionScope.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import org.apache.hop.core.scope.IHopScope;
+import org.eclipse.rap.rwt.SingletonUtil;
+
+/**
+ * Holds one piece of Hop's ambient state per RAP session, so that one user's 
project does not
+ * become another's. This is what {@link 
org.apache.hop.metadata.util.HopMetadataInstance} and
+ * {@link org.apache.hop.core.vfs.HopVfsNamespaces} keep their "current" value 
in when Hop runs as
+ * Hop Web.
+ *
+ * <p>Not every thread that asks has a RAP session: an execution runs on 
threads Hop started itself,
+ * and those have no request and no {@code UISession}. So the value is kept 
twice - in the session
+ * for whoever is serving a request, and in a thread local inherited by 
threads an execution starts.
+ * A pipeline launched from the GUI therefore keeps resolving in the session 
that launched it, while
+ * a second session serving a request alongside it gets its own.
+ *
+ * <p>One instance per kind of state, each with its own holder class: {@link 
SingletonUtil} keys the
+ * per session instance on that class.
+ *
+ * @param <T> the type of the value held
+ */
+public class RapSessionScope<T> implements IHopScope<T> {
+
+  /** Kept per RAP session by {@link SingletonUtil}. One subclass per kind of 
state. */
+  public static class Holder {
+    Object value;
+  }
+
+  /** The session slot for the metadata provider in use. */
+  public static class MetadataProviderHolder extends Holder {}
+
+  /** The session slot for the VFS namespace files are resolved in. */
+  public static class VfsNamespaceHolder extends Holder {}
+
+  private final Class<? extends Holder> holderType;
+
+  /** For the threads of an execution, which have no session to ask. */
+  private final InheritableThreadLocal<T> outsideASession = new 
InheritableThreadLocal<>();
+
+  public RapSessionScope(Class<? extends Holder> holderType) {
+    this.holderType = holderType;
+  }
+
+  @Override
+  public T get() {
+    return valueOf(holder());
+  }
+
+  /**
+   * The answer for a given session slot. Kept apart from {@link #get()} so it 
can be exercised
+   * without a RAP session, which is the only way to reach the case where a 
session exists but has
+   * nothing bound.
+   *
+   * @param holder the session's slot, or null when this thread is serving no 
session
+   * @return the value in scope
+   */
+  @SuppressWarnings("unchecked")
+  T valueOf(Holder holder) {
+    if (holder != null) {
+      // Whether this session has anything bound or not, the answer is its 
own. Falling back to the
+      // thread here would hand it whatever the previous session on this 
pooled request thread
+      // left behind.
+      return (T) holder.value;
+    }
+    return outsideASession.get();
+  }
+
+  @Override
+  public void set(T value) {
+    Holder holder = holder();
+    if (holder != null) {
+      holder.value = value;
+    }
+    // Also outside the session: an execution started from here runs on its 
own threads, and they
+    // have to keep resolving the way the session that launched them does.
+    outsideASession.set(value);
+  }
+
+  @Override
+  public void remove() {
+    Holder holder = holder();
+    if (holder != null) {
+      holder.value = null;
+    }
+    outsideASession.remove();
+  }
+
+  /** The session's holder, or null when this thread is not serving a session. 
*/
+  private Holder holder() {
+    try {
+      return SingletonUtil.getSessionInstance(holderType);
+    } catch (Exception e) {
+      // No UISession on this thread: an execution thread, or a shutdown hook.
+      return null;
+    }
+  }
+}
diff --git 
a/rap/src/test/java/org/apache/hop/ui/hopgui/RapSessionScopeTest.java 
b/rap/src/test/java/org/apache/hop/ui/hopgui/RapSessionScopeTest.java
new file mode 100644
index 0000000000..5ab823435a
--- /dev/null
+++ b/rap/src/test/java/org/apache/hop/ui/hopgui/RapSessionScopeTest.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Hop Web serves many people from one JVM on a pool of request threads, so 
the same thread serves
+ * one session and then another. What a session sees has to be its own, and 
only a thread with no
+ * session at all - an execution thread - may fall back to what it inherited.
+ */
+class RapSessionScopeTest {
+
+  private RapSessionScope<String> scope() {
+    return new RapSessionScope<>(RapSessionScope.VfsNamespaceHolder.class);
+  }
+
+  @Test
+  @DisplayName("A session with nothing bound gets nothing, not the previous 
session's value")
+  void aSessionNeverInheritsFromThePooledThread() {
+    RapSessionScope<String> scope = scope();
+    RapSessionScope.Holder sessionA = new RapSessionScope.VfsNamespaceHolder();
+    RapSessionScope.Holder sessionB = new RapSessionScope.VfsNamespaceHolder();
+
+    // Session A is served on this thread and binds something. set() also 
writes the thread local,
+    // so that executions it starts inherit it.
+    sessionA.value = "namespace of A";
+    scope.set("namespace of A");
+
+    assertEquals("namespace of A", scope.valueOf(sessionA));
+    assertNull(
+        scope.valueOf(sessionB),
+        "Session B has opened nothing and must not be handed what session A 
left on this thread");
+  }
+
+  @Test
+  @DisplayName("A thread with no session at all falls back to what it 
inherited")
+  void anExecutionThreadFallsBackToTheThread() {
+    RapSessionScope<String> scope = scope();
+    scope.set("namespace of the session that started this execution");
+
+    assertEquals(
+        "namespace of the session that started this execution",
+        scope.valueOf(null),
+        "An execution runs on threads with no UISession and has to keep 
resolving as its session does");
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index 3e76385473..cbda06fa44 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -81,9 +81,13 @@ import org.apache.hop.core.util.TranslateUtil;
 import org.apache.hop.core.variables.DescribedVariable;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.core.vfs.HopVfsNamespace;
+import org.apache.hop.core.vfs.HopVfsNamespaces;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.i18n.LanguageChoice;
 import org.apache.hop.metadata.api.IHasHopMetadataProvider;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 import org.apache.hop.metadata.util.HopMetadataInstance;
 import org.apache.hop.metadata.util.HopMetadataUtil;
@@ -378,7 +382,7 @@ public class HopGui
     this.id = UUID.randomUUID().toString();
 
     commandLineArguments = new ArrayList<>();
-    variables = Variables.getADefaultVariableSpace();
+    setVariables(Variables.getADefaultVariableSpace());
 
     loggingObject = new LoggingObject(APP_NAME);
     log = new LogChannel(APP_NAME);
@@ -420,6 +424,77 @@ public class HopGui
     PROVIDER = (ISingletonProvider) 
ImplementationLoader.newInstance(HopGui.class);
   }
 
+  /**
+   * Sits behind this GUI's variables and answers with the metadata of the 
project it has open, so
+   * that everything resolving a file through those variables lands in the 
right VFS namespace. In
+   * Hop Web every session has its own HopGui, so this is also what keeps one 
user's named VFS
+   * connections apart from another's. See issue #8106.
+   *
+   * <p>It is reached by walking the parent chain, never for looking a 
variable value up - {@link
+   * Variables#getVariable(String)} reads its own properties only - so 
variable inheritance is
+   * untouched.
+   */
+  private final IVariables metadataAnchor =
+      new Variables() {
+        @Override
+        public IHopMetadataProvider getMetadataProvider() {
+          return HopGui.this.metadataProvider;
+        }
+      };
+
+  /** The metadata this GUI took a VFS namespace for, so it can let go of it 
again. */
+  private IHopMetadataProvider vfsNamespaceProvider;
+
+  /**
+   * Take the VFS namespace of the project this GUI now has open, and let go 
of the previous one.
+   *
+   * <p>Only does anything where tenants share the JVM: in Hop Web this is 
what gives each session
+   * its own named VFS connections, so two people can both have a connection 
called {@code mydata}
+   * pointing somewhere different. On the desktop there is one project in the 
process and the
+   * process wide file system manager already holds its connections, so this 
reads them again
+   * instead. See Apache Hop issue #8106.
+   */
+  public void useVfsNamespaceOfOpenProject() {
+    IHopMetadataProvider previousProvider = vfsNamespaceProvider;
+    HopVfsNamespace namespace =
+        HopVfsNamespaces.acquire(variables, metadataProvider, "Hop GUI " + id);
+    vfsNamespaceProvider = namespace == null ? null : metadataProvider;
+
+    // Bind it for this session, so the call sites that resolve a file with no 
variables in hand
+    // land in it too. Deliberately not restored afterwards: it stays for as 
long as the session.
+    HopVfsNamespaces.bindThread(namespace);
+
+    if (previousProvider != null) {
+      HopVfsNamespaces.release(previousProvider);
+    }
+    if (namespace == null) {
+      HopVfs.refresh(variables);
+    }
+  }
+
+  /** Let go of the VFS namespace of this GUI, when its session ends. */
+  public void releaseVfsNamespace() {
+    if (vfsNamespaceProvider != null) {
+      HopVfsNamespaces.release(vfsNamespaceProvider);
+      vfsNamespaceProvider = null;
+    }
+    // Put back nothing: this session is going away.
+    HopVfsNamespaces.restoreThread(null);
+  }
+
+  /**
+   * Give this GUI a new set of variables, as loading a project does. The 
metadata of the open
+   * project stays reachable from them either way.
+   *
+   * @param variables the variables to use
+   */
+  public void setVariables(IVariables variables) {
+    this.variables = variables;
+    if (variables != null) {
+      variables.setParentVariables(metadataAnchor);
+    }
+  }
+
   public static HopGui getInstance() {
     return (HopGui) PROVIDER.getInstanceInternal();
   }

Reply via email to