---
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](/typescript/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](/typescript/setup/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](/typescript/runtime/sandbox#the-workspace-inside-the-sandbox)
still applies: serve them on the machine with Mirage + FUSE at the same
prefixes.

## Connect from TypeScript

```ts
import { MountMode, SSHResource, SSHRuntime, Workspace } from '@struktoai/mirage-node'

const runtime = new SSHRuntime({
  captures: ['python3'],
  config: { host: 'mybox', username: 'deploy', identityFile: '~/.ssh/id_ed25519' },
})
const proj = new SSHResource({
  host: 'mybox',
  username: 'deploy',
  identityFile: '~/.ssh/id_ed25519',
  root: '/home/deploy/proj',
})
const ws = new 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), `port`, `username`,
`identityFile`, `timeout`, and the shared `env`. Auth is keys or an agent,
never a password: with no `identityFile`, `SSH_AUTH_SOCK` authenticates, and
`username` defaults to the local user. `ssh2` is the transport and loads on
first use (`pnpm add ssh2`).
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** (ssh2 never checks them), 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](/typescript/runtime/sandbox#resource-limits).
</Warning>
