diff --git a/reproman/resource/session.py b/reproman/resource/session.py
index 776701c..ba80322 100644
--- a/reproman/resource/session.py
+++ b/reproman/resource/session.py
@@ -508,6 +508,50 @@ class Session(object):
         raise NotImplementedError
 
 
+    def transfer_recursive(self, src_path, dest_path, isdir_fct, listdir_fct, mkdir_fct, cp_fct):
+        """Recursively transfer files and directories.
+
+        Traversing the source tree contains a lot of the same logic whether 
+        we're putting or getting, so we abstract that out here.  The cost is 
+        that we need to pass the functions needed for the operation:
+
+            isdir_fct(src_path)
+
+            listdir_fct(src_path)
+
+            mkdir_fct(dest_path)
+
+            cp_fct(src_path, dest_path)
+
+        We unwrap the recursion so we can calculate the relative path names 
+        from the base source path.
+        """
+
+        # a simple copy now if the source is a file...
+        if not isdir_fct(src_path):
+            cp_fct(src_path, dest_path)
+            return
+        # ...otherwise we src_path for relative paths
+
+        stack = [src_path]
+        while stack:
+            path = stack.pop()
+            src_relpath = os.path.relpath(path, src_path)
+            dest_fullpath = os.path.join(dest_path, src_relpath)
+            if isdir_fct(path):
+                # For src_path, relpath() gives '.', so dest_fullpath ends 
+                # with "/.".  mkdir doesn't like this, so we handle src_path 
+                # as a special case.
+                if path == src_path:
+                    mkdir_fct(dest_path)
+                else:
+                    mkdir_fct(dest_fullpath)
+                stack.extend(os.path.join(path, p) for p in listdir_fct(path))
+            else:
+                cp_fct(path, dest_fullpath)
+        return
+
+
 @attr.s
 class POSIXSession(Session):
     """A Session which relies on commands present in any POSIX-compliant env
diff --git a/reproman/resource/shell.py b/reproman/resource/shell.py
index 316ed8c..4e9558e 100644
--- a/reproman/resource/shell.py
+++ b/reproman/resource/shell.py
@@ -84,7 +84,10 @@ class ShellSession(POSIXSession):
     @borrowdoc(Session)
     def get(self, src_path, dest_path=None, uid=-1, gid=-1):
         dest_path = self._prepare_dest_path(src_path, dest_path)
-        shutil.copy(src_path, dest_path)
+        if os.path.isdir(src_path):
+            shutil.copytree(src_path, dest_path)
+        else:
+            shutil.copy(src_path, dest_path)
         if uid > -1 or gid > -1:
             self.chown(dest_path, uid, gid, recursive=True)
 
@@ -157,4 +160,4 @@ class Shell(Resource):
             raise NotImplementedError
         if shared:
             raise NotImplementedError
-        return ShellSession()
\ No newline at end of file
+        return ShellSession()
diff --git a/reproman/resource/singularity.py b/reproman/resource/singularity.py
index bd87c42..537afec 100644
--- a/reproman/resource/singularity.py
+++ b/reproman/resource/singularity.py
@@ -186,8 +186,7 @@ class SingularitySession(POSIXSession):
 
         return (stdout, stderr)
 
-    @borrowdoc(Session)
-    def put(self, src_path, dest_path, uid=-1, gid=-1):
+    def _put_file(self, src_path, dest_path):
         dest_path = self._prepare_dest_path(src_path, dest_path,
                                             local=False, absolute_only=True)
         cmd = 'cat {} | singularity exec instance://{} tee {} > /dev/null'
@@ -195,19 +194,50 @@ class SingularitySession(POSIXSession):
                                     self.name,
                                     shlex_quote(dest_path)))
 
+    @borrowdoc(Session)
+    def put(self, src_path, dest_path, uid=-1, gid=-1):
+        self.transfer_recursive(
+            src_path, 
+            dest_path, 
+            os.path.isdir, 
+            os.listdir, 
+            self.mkdir, 
+            self._put_file
+        )
+
         if uid > -1 or gid > -1:
-            self.chown(dest_path, uid, gid)
+            self.chown(dest_path, uid, gid, recursive=True)
 
-    @borrowdoc(Session)
-    def get(self, src_path, dest_path=None, uid=-1, gid=-1):
+    def _get_file(self, src_path, dest_path):
         dest_path = self._prepare_dest_path(src_path, dest_path)
         cmd = 'singularity exec instance://{} cat {} > {}'
         self._runner.run(cmd.format(self.name,
                                     shlex_quote(src_path),
                                     shlex_quote(dest_path)))
 
+    @borrowdoc(Session)
+    def get(self, src_path, dest_path=None, uid=-1, gid=-1):
+        self.transfer_recursive(
+            src_path, 
+            dest_path, 
+            self.isdir, 
+            self.listdir, 
+            os.mkdir, 
+            self._get_file
+        )
+
         if uid > -1 or gid > -1:
-            self.chown(dest_path, uid, gid, remote=False)
+            self.chown(dest_path, uid, gid, remote=False, recursive=True)
+
+    def listdir(self, path):
+        cmd = ['singularity', 
+                'exec', 
+               'instance://{}'.format(self.name), 
+               'ls', 
+               '-1', 
+               path]
+        (stdout, stderr) = self._runner.run(cmd)
+        return [ f for f in stdout.split('\n') if f not in ('', '.', '..') ]
 
 
 @attr.s
diff --git a/reproman/resource/ssh.py b/reproman/resource/ssh.py
index 4153d8d..ebd1668 100644
--- a/reproman/resource/ssh.py
+++ b/reproman/resource/ssh.py
@@ -8,6 +8,8 @@
 """Resource sub-class to provide management of a SSH connection."""
 
 import attr
+import os
+import stat
 import getpass
 import uuid
 from ..log import LoggerHelper
@@ -218,18 +220,34 @@ class SSHSession(POSIXSession):
     @borrowdoc(Session)
     def put(self, src_path, dest_path, uid=-1, gid=-1):
         dest_path = self._prepare_dest_path(src_path, dest_path, local=False)
-        self.connection.put(src_path, dest_path)
+        sftp = self.connection.sftp()
+        self.transfer_recursive(
+            src_path, 
+            dest_path, 
+            os.path.isdir, 
+            os.listdir, 
+            sftp.mkdir, 
+            self.connection.put
+        )
 
         if uid > -1 or gid > -1:
-            self.chown(dest_path, uid, gid)
+            self.chown(dest_path, uid, gid, recursive=True)
 
     @borrowdoc(Session)
     def get(self, src_path, dest_path=None, uid=-1, gid=-1):
         dest_path = self._prepare_dest_path(src_path, dest_path)
-        self.connection.get(src_path, dest_path)
+        sftp = self.connection.sftp()
+        self.transfer_recursive(
+            src_path, 
+            dest_path, 
+            lambda f: stat.S_ISDIR(sftp.stat(f).st_mode), 
+            sftp.listdir, 
+            os.mkdir, 
+            self.connection.get
+        )
 
         if uid > -1 or gid > -1:
-            self.chown(dest_path, uid, gid, remote=False)
+            self.chown(dest_path, uid, gid, remote=False, recursive=True)
 
 
 @attr.s

ENH: recursive `get` for sessions (shell, ssh, etc) should support recursive mode of operation
It is necessary for any place where `get` is supposed to obtain a full hierarchy of directories like e.g. getting outputs of the job back to the local system.  

I will send a follow up initial PR for the plain orchestrator to fetch everything not just failed subjobs logs
diff --git a/reproman/interface/tests/test_run.py b/reproman/interface/tests/test_run.py
index c12531a..efacca7 100644
--- a/reproman/interface/tests/test_run.py
+++ b/reproman/interface/tests/test_run.py
@@ -493,3 +493,28 @@ def test_jobs_orc_error(context):
                 jobs(queries=[], status=True)
             assert "myshell" not in output.out
             assert "resurrection failed" in log.out
+
+
+def test_recursive_transfer(context):
+    run = context["run_fn"]
+    path = context["directory"]
+    jobs = context["jobs_fn"]
+
+    # Our script takes an inventory of the execute directory so we can be 
+    # sure that all of the files have transferred.  It then creates a tree 
+    # that we verify is returned.
+
+    create_tree(path, {"script": "find . > out_file ; mkdir out_dir ; touch out_dir/subfile", 
+                       "in_dir": {"subfile": ""}})
+
+    with swallow_outputs() as output:
+        run(command=["sh", "-e", "script"], 
+            inputs=["script", "in_dir"], 
+            outputs=["out_file", "out_dir"], 
+            resref="myshell")
+        try_fetch(lambda: jobs(queries=[], action="fetch", all_=True))
+        assert op.exists(op.join(path, "out_file"))
+        with open(op.join(path, "out_file")) as fo:
+            lines = [ line.strip() for line in fo ]
+        assert "./in_dir/subfile" in lines
+        assert op.exists(op.join(path, "out_dir", "subfile"))
diff --git a/reproman/resource/tests/test_session.py b/reproman/resource/tests/test_session.py
index 8dd551e..9ae51df 100644
--- a/reproman/resource/tests/test_session.py
+++ b/reproman/resource/tests/test_session.py
@@ -18,6 +18,7 @@ import uuid
 from ..session import get_updated_env, Session
 from ...support.exceptions import CommandError
 from ...utils import chpwd, swallow_logs
+from ...tests.utils import create_tree
 from ...tests.skip import mark
 from ...tests.fixtures import get_docker_fixture
 from ...tests.fixtures import get_singularity_fixture
@@ -260,11 +261,13 @@ def check_methods(resource_test_dir):
         assert not result
 
         # Create a temporary test file
-        temp_file = tempfile.NamedTemporaryFile(dir=resource_test_dir)
-        with temp_file as f:
-            f.write('ReproMan test content\nline 2\nline 3'.encode('utf8'))
-            f.flush()
-            local_path = temp_file.name
+        with tempfile.TemporaryDirectory(dir=resource_test_dir) as tdir:
+            create_tree(tdir,
+                        {'f0': 'ReproMan test content\nline 2\nline 3',
+                         'f1': 'f1',
+                         'd0': {'f2': 'f2',
+                                'd2': {'f3': 'f3'}}})
+            local_path = os.path.join(tdir, 'f0')
             remote_path = '{}/reproman upload/{}'.format(resource_test_dir,
                 uuid.uuid4().hex)
 
@@ -276,6 +279,13 @@ def check_methods(resource_test_dir):
             assert result
             # TODO: Check uid and gid of remote file
 
+            # Check recursive put().
+            remote_path_rec = '{}/recursive-put/{}'.format(
+                resource_test_dir, uuid.uuid4().hex)
+            session.put(tdir, remote_path_rec)
+            assert session.exists(remote_path_rec + "/d0/f2")
+            assert session.exists(remote_path_rec + "/d0/d2/f3")
+
             # We can use a relative name for the target
             basename_put_dir = os.path.join(resource_test_dir, "basename-put")
             if not os.path.exists(basename_put_dir):
@@ -330,6 +340,14 @@ def check_methods(resource_test_dir):
         result = session.isdir(test_dir)
         assert result
 
+        # Check listdir() method
+        if hasattr(session, 'listdir'):
+            subdir = uuid.uuid4().hex
+            subfile = uuid.uuid4().hex
+            session.mkdir(os.path.join(test_dir, subdir))
+            session.put('/etc/hosts', os.path.join(test_dir, subfile))
+            assert set(session.listdir(test_dir)) == set((subdir, subfile))
+
         # Check making parent dirs without setting flag
         test_dir = '{}/tmp/i fail/{}'.format(resource_test_dir, uuid.uuid4().hex)
         with pytest.raises(CommandError):

pip install -e .[devel]
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.16
annexremote==1.6.6
appdirs==1.4.4
attrs==25.3.0
babel==2.17.0
backports.tarfile==1.2.0
bcrypt==4.3.0
boto3==1.37.23
botocore==1.37.23
certifi==2025.1.31
cffi==1.17.1
chardet==5.2.0
charset-normalizer==3.4.1
coverage==7.8.0
cryptography==44.0.2
datalad==1.1.5
datalad_container==1.2.5
decorator==5.2.1
Deprecated==1.2.18
distro==1.9.0
docker==7.1.0
dockerpty==0.4.1
docutils==0.21.2
exceptiongroup==1.2.2
execnet==2.1.1
fabric==3.2.2
fasteners==0.19
humanize==4.12.2
idna==3.10
imagesize==1.4.1
importlib_metadata==8.6.1
importlib_resources==6.5.2
iniconfig==2.1.0
invoke==2.2.0
iso8601==2.1.0
isodate==0.7.2
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.1.0
jeepney==0.9.0
Jinja2==3.1.6
jmespath==1.0.1
keyring==25.6.0
keyrings.alt==5.0.2
line_profiler==4.2.0
looseversion==1.3.0
MarkupSafe==3.0.2
more-itertools==10.6.0
msgpack==1.1.0
nibabel==5.3.2
numpy==2.0.2
packaging==24.2
paramiko==3.5.1
patool==1.15.0
platformdirs==4.3.7
pluggy==1.5.0
pycparser==2.22
pycrypto==2.6.1
Pygments==2.19.1
PyNaCl==1.5.0
pyOpenSSL==16.2.0
pyparsing==3.2.3
pytest==8.3.5
pytest-asyncio==0.26.0
pytest-cov==6.0.0
pytest-mock==3.14.0
pytest-xdist==3.6.1
python-dateutil==2.9.0.post0
python-debian==1.0.1
python-gitlab==5.6.0
pytz==2025.2
PyYAML==6.0.2
rdflib==7.1.4
-e git+https://github.com/ReproNim/reproman.git@b82a412ecccce33c885247dbd9464972b0e87c3f#egg=reproman
reprozip==1.3
requests==2.32.3
requests-toolbelt==1.0.0
rpaths==1.0.0
s3transfer==0.11.4
scp==0.15.0
SecretStorage==3.3.3
six==1.17.0
snowballstemmer==2.2.0
Sphinx==7.4.7
sphinx-rtd-theme==3.0.2
sphinxcontrib-applehelp==2.0.0
sphinxcontrib-devhelp==2.0.0
sphinxcontrib-htmlhelp==2.1.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==2.0.0
sphinxcontrib-serializinghtml==2.0.0
swebench_matterhorn @ file:///swebench_matterhorn
tomli==2.2.1
tqdm==4.67.1
typing_extensions==4.13.0
urllib3==1.26.20
usagestats==1.0.1
wrapt==1.17.2
zipp==3.21.0

name: reproman
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.16
      - annexremote==1.6.6
      - appdirs==1.4.4
      - attrs==25.3.0
      - babel==2.17.0
      - backports-tarfile==1.2.0
      - bcrypt==4.3.0
      - boto3==1.37.23
      - botocore==1.37.23
      - certifi==2025.1.31
      - cffi==1.17.1
      - chardet==5.2.0
      - charset-normalizer==3.4.1
      - coverage==7.8.0
      - cryptography==44.0.2
      - datalad==1.1.5
      - datalad-container==1.2.5
      - decorator==5.2.1
      - deprecated==1.2.18
      - distro==1.9.0
      - docker==7.1.0
      - dockerpty==0.4.1
      - docutils==0.21.2
      - exceptiongroup==1.2.2
      - execnet==2.1.1
      - fabric==3.2.2
      - fasteners==0.19
      - humanize==4.12.2
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==8.6.1
      - importlib-resources==6.5.2
      - iniconfig==2.1.0
      - invoke==2.2.0
      - iso8601==2.1.0
      - isodate==0.7.2
      - jaraco-classes==3.4.0
      - jaraco-context==6.0.1
      - jaraco-functools==4.1.0
      - jeepney==0.9.0
      - jinja2==3.1.6
      - jmespath==1.0.1
      - keyring==25.6.0
      - keyrings-alt==5.0.2
      - line-profiler==4.2.0
      - looseversion==1.3.0
      - markupsafe==3.0.2
      - more-itertools==10.6.0
      - msgpack==1.1.0
      - nibabel==5.3.2
      - numpy==2.0.2
      - packaging==24.2
      - paramiko==3.5.1
      - patool==1.15.0
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pycparser==2.22
      - pycrypto==2.6.1
      - pygments==2.19.1
      - pynacl==1.5.0
      - pyopenssl==16.2.0
      - pyparsing==3.2.3
      - pytest==8.3.5
      - pytest-asyncio==0.26.0
      - pytest-cov==6.0.0
      - pytest-mock==3.14.0
      - pytest-xdist==3.6.1
      - python-dateutil==2.9.0.post0
      - python-debian==1.0.1
      - python-gitlab==5.6.0
      - pytz==2025.2
      - pyyaml==6.0.2
      - rdflib==7.1.4
      - reprozip==1.3
      - requests==2.32.3
      - requests-toolbelt==1.0.0
      - rpaths==1.0.0
      - s3transfer==0.11.4
      - scp==0.15.0
      - secretstorage==3.3.3
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==7.4.7
      - sphinx-rtd-theme==3.0.2
      - sphinxcontrib-applehelp==2.0.0
      - sphinxcontrib-devhelp==2.0.0
      - sphinxcontrib-htmlhelp==2.1.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==2.0.0
      - sphinxcontrib-serializinghtml==2.0.0
      - swebench-matterhorn==0.0.0
      - tomli==2.2.1
      - tqdm==4.67.1
      - typing-extensions==4.13.0
      - urllib3==1.26.20
      - usagestats==1.0.1
      - wrapt==1.17.2
      - zipp==3.21.0
prefix: /opt/conda/envs/reproman

reproman/interface/tests/test_run.py::test_recursive_transfer reproman/resource/tests/test_session.py::test_session_shell
reproman/interface/tests/test_run.py::test_run_no_command[None] reproman/interface/tests/test_run.py::test_run_no_command[[]] reproman/interface/tests/test_run.py::test_run_no_resource reproman/interface/tests/test_run.py::test_run_list[-expected0] reproman/interface/tests/test_run.py::test_run_list[submitters-expected1] reproman/interface/tests/test_run.py::test_run_list[orchestrators-expected2] reproman/interface/tests/test_run.py::test_run_list[parameters-expected3] reproman/interface/tests/test_run.py::test_combine_specs[empty] reproman/interface/tests/test_run.py::test_combine_specs[exclusive] reproman/interface/tests/test_run.py::test_combine_specs[simple-update] reproman/interface/tests/test_run.py::test_combine_specs[mapping-to-atom] reproman/interface/tests/test_run.py::test_combine_specs[atom-to-mapping] reproman/interface/tests/test_run.py::test_combine_batch_params[empty] reproman/interface/tests/test_run.py::test_combine_batch_params[one] reproman/interface/tests/test_run.py::test_combine_batch_params[two, reproman/interface/tests/test_run.py::test_combine_batch_params[two reproman/interface/tests/test_run.py::test_combine_batch_params[= reproman/interface/tests/test_run.py::test_combine_batch_params[spaces] reproman/interface/tests/test_run.py::test_combine_batch_params_glob reproman/interface/tests/test_run.py::test_combine_batch_params_repeat_key reproman/interface/tests/test_run.py::test_combine_batch_params_no_equal reproman/interface/tests/test_run.py::test_run_batch_spec_and_params reproman/interface/tests/test_run.py::test_resolve_batch_params_eq[empty] reproman/interface/tests/test_run.py::test_resolve_batch_params_eq[one] reproman/interface/tests/test_run.py::test_resolve_batch_params_eq[two, reproman/interface/tests/test_run.py::test_run_resource_specification reproman/interface/tests/test_run.py::test_run_and_fetch reproman/interface/tests/test_run.py::test_run_and_follow reproman/interface/tests/test_run.py::test_run_and_follow_action[stop] reproman/interface/tests/test_run.py::test_run_and_follow_action[stop-if-success] reproman/interface/tests/test_run.py::test_run_and_follow_action[delete] reproman/interface/tests/test_run.py::test_run_and_follow_action[delete-if-success] reproman/interface/tests/test_run.py::test_jobs_auto_fetch_with_query reproman/interface/tests/test_run.py::test_jobs_query_unknown reproman/interface/tests/test_run.py::test_jobs_delete reproman/interface/tests/test_run.py::test_jobs_show reproman/interface/tests/test_run.py::test_jobs_unknown_action reproman/interface/tests/test_run.py::test_jobs_ambig_id_match reproman/interface/tests/test_run.py::test_jobs_deleted_resource reproman/interface/tests/test_run.py::test_jobs_deleted_local_directory reproman/interface/tests/test_run.py::test_jobs_orc_error reproman/resource/tests/test_session.py::test_get_updated_env reproman/resource/tests/test_session.py::test_get_local_session reproman/resource/tests/test_session.py::test_session_class
reproman/resource/shell.py reproman/resource/singularity.py reproman/resource/ssh.py reproman/resource/session.py
reproman/resource/shell.py reproman/resource/singularity.py reproman/resource/ssh.py reproman/resource/session.py