+ lelab makes no assumption about what's installed on this server — it
+ copies the dataset over via scp and runs the command below as-is.
+ Make sure lerobot is already set up there.
+
+ Whatever it takes to reach an interpreter with lerobot installed —
+ lelab runs this verbatim, followed by the training command.
+
+
+
+ )}
);
diff --git a/frontend/src/components/training/types.ts b/frontend/src/components/training/types.ts
index d0ee5f44..b2343bf9 100644
--- a/frontend/src/components/training/types.ts
+++ b/frontend/src/components/training/types.ts
@@ -1,5 +1,18 @@
+export interface SshConnectionConfig {
+ host: string;
+ port: number;
+ username: string;
+ ssh_key_path?: string;
+ remote_workdir: string;
+ remote_python_cmd: string;
+}
+
export interface TrainingConfig {
- target: { runner: "local" | "hf_cloud"; flavor?: string };
+ target: {
+ runner: "local" | "hf_cloud" | "ssh_remote";
+ flavor?: string;
+ ssh?: SshConnectionConfig;
+ };
// Dataset configuration
dataset_repo_id: string;
diff --git a/frontend/src/lib/jobsApi.ts b/frontend/src/lib/jobsApi.ts
index 088b727b..6ad6709e 100644
--- a/frontend/src/lib/jobsApi.ts
+++ b/frontend/src/lib/jobsApi.ts
@@ -50,7 +50,18 @@ export interface TrainingRequest {
optimizer_grad_clip_norm?: number;
use_policy_training_preset: boolean;
// Optional target for runner dispatch; omitted ⇒ local.
- target?: { runner: "local" | "hf_cloud"; flavor?: string };
+ target?: {
+ runner: "local" | "hf_cloud" | "ssh_remote";
+ flavor?: string;
+ ssh?: {
+ host: string;
+ port: number;
+ username: string;
+ ssh_key_path?: string;
+ remote_workdir: string;
+ remote_python_cmd: string;
+ };
+ };
}
export interface JobRecord {
diff --git a/frontend/src/pages/Training.tsx b/frontend/src/pages/Training.tsx
index a361d042..2cda04b1 100644
--- a/frontend/src/pages/Training.tsx
+++ b/frontend/src/pages/Training.tsx
@@ -275,6 +275,11 @@ const ConfigurationMode: React.FC = () => {
const targetRequiresAuth = trainingConfig.target.runner === "hf_cloud";
const targetMissingFlavor =
trainingConfig.target.runner === "hf_cloud" && !trainingConfig.target.flavor;
+ const targetMissingSshFields =
+ trainingConfig.target.runner === "ssh_remote" &&
+ (!trainingConfig.target.ssh?.host.trim() ||
+ !trainingConfig.target.ssh?.username.trim() ||
+ !trainingConfig.target.ssh?.remote_workdir.trim());
const localBlocked =
trainingConfig.target.runner === "local" && localJobRunning;
const startDisabled =
@@ -282,13 +287,16 @@ const ConfigurationMode: React.FC = () => {
!trainingConfig.dataset_repo_id.trim() ||
localBlocked ||
(targetRequiresAuth && !authenticated) ||
- targetMissingFlavor;
+ targetMissingFlavor ||
+ targetMissingSshFields;
const startTooltip = localBlocked
? "Another local training is already running"
: targetRequiresAuth && !authenticated
? "Log in to Hugging Face to use cloud compute"
: targetMissingFlavor
? "Select a hardware flavor"
+ : targetMissingSshFields
+ ? "Fill in host, username, and remote working directory"
: undefined;
return (
diff --git a/lelab/jobs.py b/lelab/jobs.py
index 10810a16..088b1d6b 100644
--- a/lelab/jobs.py
+++ b/lelab/jobs.py
@@ -46,12 +46,37 @@
JobState = Literal["running", "done", "failed", "interrupted"]
+class SshConnectionConfig(BaseModel):
+ """Connection details for a user-owned remote training server.
+
+ Deliberately makes no assumption about what's installed on the remote
+ host: `remote_python_cmd` is a free-form shell fragment (e.g. "python",
+ "/opt/venv/bin/python", "source ~/venv/bin/activate && python") rather
+ than a hardcoded interpreter path, since lelab has no way to know how
+ the user's server is set up.
+ """
+
+ host: str
+ port: int = 22
+ username: str
+ # Path to a private key file. None ⇒ rely on the local ssh agent /
+ # default identity files (~/.ssh/id_rsa, etc.) — never a password: lelab
+ # doesn't handle interactive auth or store secrets.
+ ssh_key_path: str | None = None
+ # Absolute path on the remote host under which datasets/outputs are
+ # staged for this job (e.g. "/home/user/lelab-runs").
+ remote_workdir: str
+ remote_python_cmd: str = "python"
+
+
class JobTarget(BaseModel):
"""Where a job should run. `local` ⇒ LocalJobRunner. `hf_cloud` requires
- a non-empty `flavor` from HfApi.list_jobs_hardware()."""
+ a non-empty `flavor` from HfApi.list_jobs_hardware(). `ssh_remote`
+ requires `ssh`."""
- runner: Literal["local", "hf_cloud"] = "local"
+ runner: Literal["local", "hf_cloud", "ssh_remote"] = "local"
flavor: str | None = None
+ ssh: SshConnectionConfig | None = None
class TrainingMetrics(BaseModel):
@@ -79,15 +104,23 @@ class JobRecord(BaseModel):
exit_code: int | None = None
error_message: str | None = None
metrics: TrainingMetrics = TrainingMetrics()
- runner: Literal["local", "hf_cloud", "imported"] = "local"
- # PID of the detached subprocess (local runner only); survives uvicorn
- # --reload so a fresh registry can re-attach by tailing the log file.
+ runner: Literal["local", "hf_cloud", "ssh_remote", "imported"] = "local"
+ # PID of the detached subprocess (local + ssh_remote runners: for
+ # ssh_remote this is the local `ssh` client's pid, not a remote pid).
+ # Survives uvicorn --reload so a fresh registry can re-attach by tailing
+ # the log file.
process_pid: int | None = None
# HF Jobs identifiers (hf_cloud runner only)
hf_job_id: str | None = None
hf_flavor: str | None = None
hf_repo_id: str | None = None
hf_job_url: str | None = None
+ # ssh_remote runner only: connection used to start this job (so checkpoint
+ # pulls still work after a lelab restart) and the remote output directory
+ # the runner picked (outside record.output_dir, which stays a local path
+ # for _list_local_checkpoints to scan after a pull).
+ ssh_config: SshConnectionConfig | None = None
+ ssh_remote_dir: str | None = None
# Captured from training stdout the first time wandb prints the run URL.
wandb_run_url: str | None = None
# Number of checkpoints currently visible (local: filesystem; cloud:
@@ -523,6 +556,11 @@ def _list_local_checkpoints(output_dir: str) -> list[JobCheckpoint]:
_CLOUD_CKPT_TTL_SECONDS = 30.0
+
+# Min seconds between scp checkpoint pulls for a single ssh_remote job. A full
+# recursive scp of the checkpoints/ tree isn't cheap, and the frontend polls
+# checkpoints every ~5s — this keeps that from hammering the remote host.
+_SSH_CHECKPOINT_PULL_INTERVAL_S = 30.0
_CKPT_PATH_RE = re.compile(r"^checkpoints/(\d+)/pretrained_model/config\.json$")
@@ -672,6 +710,9 @@ def __init__(self, output_root: Path) -> None:
# repo_id -> (expires_at_epoch, checkpoint list)
self._cloud_ckpt_cache: dict[str, tuple[float, list[JobCheckpoint]]] = {}
+ # job_id -> last scp-pull epoch, for _maybe_pull_ssh_checkpoints' rate limit.
+ self._ssh_pull_cache: dict[str, float] = {}
+
# Fired (best-effort) on every state change: new job, stop initiated,
# watchdog finalisation, delete. Server wires this to a WebSocket
# broadcast so the frontend can refetch on-event instead of polling.
@@ -797,10 +838,13 @@ def get(self, job_id: str) -> JobRecord:
def start(self, config: TrainingRequest, target: JobTarget | None = None) -> JobRecord:
from .runners.hf_cloud import HfCloudJobRunner # lazy import to avoid circular import
+ from .runners.ssh_remote import SshRemoteJobRunner # lazy import to avoid circular import
target = target or JobTarget()
if target.runner == "hf_cloud" and not target.flavor:
raise ValueError("flavor is required when runner is hf_cloud")
+ if target.runner == "ssh_remote" and not target.ssh:
+ raise ValueError("ssh connection details are required when runner is ssh_remote")
with self._lock:
# Local trainings are bounded by this machine's GPU/USB resources,
@@ -824,6 +868,7 @@ def start(self, config: TrainingRequest, target: JobTarget | None = None) -> Job
started_at=time.time(),
runner=target.runner,
hf_flavor=target.flavor,
+ ssh_config=target.ssh,
)
job_dir.mkdir(parents=True, exist_ok=True)
@@ -833,6 +878,8 @@ def start(self, config: TrainingRequest, target: JobTarget | None = None) -> Job
log_path = _job_log_path(self._output_root, job_id)
if target.runner == "local":
runner = LocalJobRunner(record.metrics, log_file_path=log_path)
+ elif target.runner == "ssh_remote":
+ runner = SshRemoteJobRunner(record.metrics, log_path, target.ssh)
else:
runner = HfCloudJobRunner(record.metrics, log_path, target.flavor)
@@ -850,8 +897,10 @@ def start(self, config: TrainingRequest, target: JobTarget | None = None) -> Job
# / page URL / model repo are printed by lerobot's submit_to_hf and
# only appear in stdout a few seconds after start, so they're None
# here; the watchdog (_tick) parses and persists them once they land.
- if target.runner == "local":
+ if target.runner in ("local", "ssh_remote"):
record.process_pid = runner.pid()
+ if target.runner == "ssh_remote":
+ record.ssh_remote_dir = runner.remote_output_dir()
self._persist(record, force=True)
self._runners[job_id] = runner
@@ -1021,11 +1070,37 @@ def _checkpoints_for(self, record: JobRecord) -> builtins.list[JobCheckpoint]:
return _list_imported_local(record.output_dir)
if record.runner == "local":
return _list_local_checkpoints(record.output_dir)
+ if record.runner == "ssh_remote":
+ # Pull the remote checkpoints/ tree into the same local output_dir
+ # a local job would use, then reuse the exact same local listing —
+ # no separate "remote" checkpoint representation needed. Rate
+ # limited so the ~5s poll from the frontend doesn't scp on every tick.
+ self._maybe_pull_ssh_checkpoints(record)
+ return _list_local_checkpoints(record.output_dir)
# Cloud: _list_imported_hub prefers the checkpoints// tree (pushed when
# save_checkpoint_to_hub is on) and falls back to the final model at the repo
# root, so a finished run is always reachable even with no per-step tree.
return self._list_cloud_cached(record.hf_repo_id)
+ def _maybe_pull_ssh_checkpoints(self, record: JobRecord) -> None:
+ """Best-effort scp pull of the remote checkpoints/ dir, rate-limited
+ per job. Silently no-ops on any failure (offline server, nothing
+ saved yet) — checkpoint listing degrades to "none yet" rather than
+ surfacing a transient network error to the whole jobs page."""
+ if not record.ssh_config or not record.ssh_remote_dir:
+ return
+ now = time.time()
+ last = self._ssh_pull_cache.get(record.id, 0.0)
+ if now - last < _SSH_CHECKPOINT_PULL_INTERVAL_S:
+ return
+ self._ssh_pull_cache[record.id] = now
+ from .runners.ssh_remote import pull_checkpoints # lazy: avoid circular import
+
+ try:
+ pull_checkpoints(record.ssh_config, record.ssh_remote_dir, record.output_dir)
+ except Exception as exc:
+ logger.info("Checkpoint pull skipped for ssh_remote job %s: %s", record.id, exc)
+
def list_checkpoints(self, job_id: str) -> builtins.list[JobCheckpoint]:
"""Return checkpoints saved for this job, ascending by step.
diff --git a/lelab/runners/ssh_remote.py b/lelab/runners/ssh_remote.py
new file mode 100644
index 00000000..a5842610
--- /dev/null
+++ b/lelab/runners/ssh_remote.py
@@ -0,0 +1,212 @@
+# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed 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.
+
+"""SSH runner — runs a training on a server the user owns and controls
+directly over SSH, rather than a HF-managed cloud container.
+
+Deliberately makes no assumption about what's already set up on the remote
+host (lerobot install, venv, CUDA...) — `SshConnectionConfig.remote_python_cmd`
+is a free-form shell fragment the user provides, and lelab just uses SSH/scp
+as a plain transport: sync the dataset up, run the training command remotely,
+tail its output, pull checkpoints back down on request.
+
+Known limitation: the dataset sync (`sync_dataset_up`) runs synchronously
+before the training subprocess is spawned, so job start blocks the request
+thread for as long as the scp takes — fine for a quick test dataset, but a
+large video dataset could make the "start training" request hang for
+minutes. Moving the sync into the background (state="staging" before
+"running") is a reasonable follow-up once this has a real server to test
+against.
+"""
+
+from __future__ import annotations
+
+import logging
+import shlex
+import subprocess
+from pathlib import Path
+
+from ..jobs import SshConnectionConfig, SubprocessJobRunner, TrainingMetrics
+from ..train import TrainingRequest, build_training_command
+
+logger = logging.getLogger(__name__)
+
+# No TTY is attached to these subprocess calls, so an interactive
+# "authenticity of host ... can't be established, continue connecting?"
+# prompt would hang forever. accept-new trusts (and pins) an unseen host key
+# automatically but still rejects a *changed* one — same safety net `ssh`
+# gives you interactively, minus the prompt.
+_SSH_OPTS = ["-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes"]
+
+
+def _resolve_local_dataset_root(config: TrainingRequest) -> Path:
+ """Same default-local-directory logic as record.py's resume fix: an
+ explicit dataset_root wins, otherwise fall back to the standard local
+ cache location for this repo_id."""
+ if config.dataset_root:
+ return Path(config.dataset_root).expanduser().resolve()
+ from lerobot.utils.constants import HF_LEROBOT_HOME
+
+ return (Path(HF_LEROBOT_HOME) / config.dataset_repo_id).resolve()
+
+
+def _ssh_base_args(ssh: SshConnectionConfig) -> list[str]:
+ args = ["ssh", *_SSH_OPTS, "-p", str(ssh.port)]
+ if ssh.ssh_key_path:
+ args += ["-i", ssh.ssh_key_path]
+ args.append(f"{ssh.username}@{ssh.host}")
+ return args
+
+
+def _scp_base_args(ssh: SshConnectionConfig) -> list[str]:
+ # scp's port flag is -P (capital), unlike ssh's -p — easy to typo.
+ args = ["scp", "-r", *_SSH_OPTS, "-P", str(ssh.port)]
+ if ssh.ssh_key_path:
+ args += ["-i", ssh.ssh_key_path]
+ return args
+
+
+def _run_ssh_command(ssh: SshConnectionConfig, remote_command: str, timeout: float = 20.0) -> str:
+ """Run one short remote command over a fresh SSH connection and return
+ its stdout. Raises RuntimeError with stderr on a non-zero exit."""
+ result = subprocess.run(
+ [*_ssh_base_args(ssh), remote_command],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"ssh command failed ({result.returncode}): {result.stderr.strip()}")
+ return result.stdout
+
+
+def sync_dataset_up(ssh: SshConnectionConfig, local_root: Path, remote_datasets_dir: str) -> str:
+ """scp -r the local dataset directory under remote_datasets_dir on the
+ remote host. Returns the resulting remote dataset directory (mirrors
+ local_root's own directory name, so this never has to guess the
+ / split of a repo_id).
+
+ No timeout: dataset video directories can be many GB, and this call is
+ expected to block for a while — see the module docstring's caveat about
+ that blocking the start-job request.
+ """
+ if not local_root.is_dir():
+ raise FileNotFoundError(
+ f"Dataset not found locally at {local_root} — record or download it before "
+ "training on a remote server (lelab only syncs what's already on this machine)."
+ )
+ _run_ssh_command(ssh, f"mkdir -p {shlex.quote(remote_datasets_dir)}")
+ scp_cmd = [*_scp_base_args(ssh), str(local_root), f"{ssh.username}@{ssh.host}:{remote_datasets_dir}/"]
+ logger.info("Syncing dataset to remote host: %s", " ".join(scp_cmd))
+ result = subprocess.run(scp_cmd, capture_output=True, text=True)
+ if result.returncode != 0:
+ raise RuntimeError(f"scp dataset sync failed ({result.returncode}): {result.stderr.strip()}")
+ return f"{remote_datasets_dir}/{local_root.name}"
+
+
+def pull_checkpoints(ssh: SshConnectionConfig, remote_output_dir: str, local_output_dir: str) -> None:
+ """scp -r the remote checkpoints/ tree down into local_output_dir, so the
+ existing local-checkpoint listing code (which just scans the filesystem)
+ picks them up unchanged. Standalone function (not a method) so a fresh
+ JobRegistry can pull checkpoints for a job whose SshRemoteJobRunner
+ instance didn't survive a lelab restart — record.ssh_config /
+ record.ssh_remote_dir are enough on their own.
+
+ Best-effort: raises on any failure (missing remote dir before the first
+ checkpoint is saved, network hiccup, ...) and the caller is expected to
+ swallow it — see JobRegistry._maybe_pull_ssh_checkpoints.
+ """
+ Path(local_output_dir).mkdir(parents=True, exist_ok=True)
+ remote_checkpoints_dir = f"{remote_output_dir}/checkpoints"
+ scp_cmd = [
+ *_scp_base_args(ssh),
+ f"{ssh.username}@{ssh.host}:{remote_checkpoints_dir}",
+ str(local_output_dir),
+ ]
+ result = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=120)
+ if result.returncode != 0:
+ raise RuntimeError(f"scp checkpoint pull failed ({result.returncode}): {result.stderr.strip()}")
+
+
+class SshRemoteJobRunner(SubprocessJobRunner):
+ """Run a training on a user-owned remote server over SSH.
+
+ Reuses SubprocessJobRunner's spawn/pump/parse pipeline exactly like
+ HfCloudJobRunner does, but the tailed subprocess here is the local `ssh`
+ client itself: its stdout mirrors the remote training command's stdout
+ for as long as the SSH session stays open, so no separate "tail -f"
+ round-trip is needed. `start_new_session=True` (set in `_spawn`) lets
+ that local ssh client — and therefore the log stream — survive a uvicorn
+ --reload the same way a local job's subprocess does.
+
+ stop() has to reach the *remote* process explicitly: killing the local
+ ssh client doesn't reliably propagate a signal to whatever it's running
+ remotely. It pattern-matches the remote output dir (unique per job, since
+ it's passed as --output_dir) via `pkill -f` over a fresh connection.
+ """
+
+ def __init__(
+ self,
+ metrics: TrainingMetrics,
+ log_file_path: Path,
+ ssh: SshConnectionConfig,
+ ) -> None:
+ super().__init__(metrics, log_file_path)
+ self._ssh = ssh
+ self._remote_output_dir: str | None = None
+
+ def start(self, job_id: str, config: TrainingRequest, output_dir: str) -> None:
+ # `output_dir` is the local path JobRegistry reserves for this job
+ # (used later to land pulled-back checkpoints) — it's not a path on
+ # the remote host, so the actual --output_dir the remote process gets
+ # is a fresh one under the user's configured remote_workdir instead.
+ local_dataset_root = _resolve_local_dataset_root(config)
+ remote_datasets_dir = f"{self._ssh.remote_workdir}/datasets"
+ remote_dataset_root = sync_dataset_up(self._ssh, local_dataset_root, remote_datasets_dir)
+
+ remote_output_dir = f"{self._ssh.remote_workdir}/outputs/{job_id}"
+ self._remote_output_dir = remote_output_dir
+
+ remote_config = config.model_copy(update={"dataset_root": remote_dataset_root})
+
+ # Build with a placeholder interpreter, then splice in
+ # remote_python_cmd unquoted — it may be a shell fragment like
+ # "source ~/venv/bin/activate && python", which shlex.quote would
+ # otherwise mangle into a single inert token.
+ cmd = build_training_command(remote_config, remote_output_dir, "__LELAB_PYEXEC__")
+ assert cmd[0] == "__LELAB_PYEXEC__"
+ quoted_rest = " ".join(shlex.quote(a) for a in cmd[1:])
+ remote_train_cmd = f"{self._ssh.remote_python_cmd} {quoted_rest}"
+
+ remote_shell = (
+ f"mkdir -p {shlex.quote(remote_output_dir)} && "
+ f"cd {shlex.quote(self._ssh.remote_workdir)} && "
+ f"{remote_train_cmd}"
+ )
+ ssh_cmd = [*_ssh_base_args(self._ssh), remote_shell]
+ logger.info("Starting ssh_remote job %s on %s: %s", job_id, self._ssh.host, remote_shell)
+ self._spawn(ssh_cmd, thread_name=f"job-{job_id}-ssh")
+
+ def remote_output_dir(self) -> str:
+ assert self._remote_output_dir is not None, "remote_output_dir() called before start()"
+ return self._remote_output_dir
+
+ def stop(self) -> None:
+ if self._remote_output_dir:
+ try:
+ _run_ssh_command(self._ssh, f"pkill -f {shlex.quote(self._remote_output_dir)}")
+ except Exception as exc:
+ # Already finished, or pkill matched nothing — fine either way.
+ logger.info("Remote pkill for %s ignored: %s", self._remote_output_dir, exc)
+ super().stop()