---
title: SSH
description: Run captured command lines on any machine you can reach over SSH.
icon: /images/ssh-logo.svg
---

`SSHRuntime` connects to a machine running sshd and executes every captured
line on it. Mirage does not start, size, or stop the machine; anything you
can already `ssh` into works, with nothing to install on it. See the
[Sandbox overview](/python/runtime/sandbox) first for routing and captures.

## Skip the FUSE

The other providers need Mirage and FUSE inside the sandbox to serve the
workspace's mounts. SSH is the one provider whose machine usually **is** the
fileserver: the [ssh resource](/python/resource/ssh) already mounts that
machine's directory over SFTP. Mount it at a prefix **equal to its remote
absolute path**, and the two sides agree about every path with no FUSE, no
remote Mirage install, and no path rewriting anywhere:

```yaml
mounts:
  /home/user/proj:            # prefix == the remote absolute path
    resource: ssh
    config:
      host: mybox
      root: /home/user/proj

runtimes:
  - name: ssh
    captures: ["python3"]
    config:
      host: mybox             # the same machine
  - vfs
```

The agent lists and greps `/home/user/proj` through the workspace (SFTP),
and `python3 /home/user/proj/train.py` runs on the box, where that path is
a real file the interpreter opens natively. One spelling of the path works
on both sides because it names the same file on the same machine. Whether
the remote directory is a plain disk, an NFS export, or itself a FUSE mount
does not matter to either side.

Nothing checks the alignment for you: a mount at a prefix that is not the
remote path (or on a different host) fails only when a captured line misses
a file. To expose a friendlier name, align from the remote side (place or
symlink the directory at that path on the box) rather than renaming the
mount: a captured line ships verbatim, and no rewrite could reach the paths
a program reads from its own code or config. For mounts the machine cannot
see natively (S3, Slack, ...) the
[general recipe](/python/runtime/sandbox#the-workspace-inside-the-sandbox)
still applies: serve them on the machine with Mirage + FUSE at the same
prefixes.

## Connect from Python

```python
from mirage import MountMode, Workspace
from mirage.resource.ssh import SSHConfig, SSHResource
from mirage.runtime.sandbox.ssh import SSHRuntime

runtime = SSHRuntime(captures=["python3"],
                     config={"host": "mybox", "username": "deploy",
                             "identity_file": "~/.ssh/id_ed25519"})
proj = SSHResource(SSHConfig(host="mybox", username="deploy",
                             identity_file="~/.ssh/id_ed25519",
                             root="/home/deploy/proj"))
ws = Workspace({"/home/deploy/proj": proj},
               mode=MountMode.EXEC,
               runtimes=[runtime, "vfs"])
await ws.execute("python3 train.py", cwd="/home/deploy/proj")
```

The config carries the ssh resource's fields minus `root`: `host` (required,
also the address unless `hostname` overrides it, and matched against
`~/.ssh/config`), `port`, `username`, `identity_file`, `timeout`, and the
shared `env`. Auth is keys or an agent, never a password. Unknown config
fields fail at construction.

## How lines run

The first captured line opens one SSH connection, and every line after it
is one exec channel on that connection: real byte stdin, separated stderr,
the remote command's own exit code. SSH exec has no docker-style `-w`/`-e`,
so the session cwd and the merged environment ride the command itself as
`cd 'cwd' && env 'K=V' ... sh -c 'line'`, every piece single-quoted. The
remote login shell only needs to be POSIX-compatible.

<Warning>
  Host keys are **not verified**, matching the ssh2 transport on the
  TypeScript side, so treat the network path to the machine as trusted. And
  as with every provider, a Mirage timeout stops waiting without killing the
  remote process; see
  [Resource limits](/python/runtime/sandbox#resource-limits).
</Warning>
