---
title: SSH Host Access
description: "Route agent tool commands to a remote host over SSH — profile fields, injected variables, how ssh-exec builds the command, key management, worked examples, and debugging"
canonical: https://giglabo.com/heretic/docs/heretic-cli/configuration/ssh
locale: en
---

# SSH Host Access

> Markdown twin of https://giglabo.com/heretic/docs/heretic-cli/configuration/ssh
> Fetch this instead of the HTML page: same content, a fraction of the bytes.
> Site structure and the full page list for agents: https://giglabo.com/llms.txt

Route agent tool commands to a remote host over SSH — profile fields, injected variables, how ssh-exec builds the command, key management, worked examples, and debugging

SSH is the second tool-execution backend: instead of a sibling builder container, missing toolchain commands are executed on a **remote host** over SSH. The agent image stays slim and needs no Docker socket.

## When SSH beats a sidecar

- **Host tools** — reach tools installed on your machine without mounting the Docker socket
- **Remote build server** — offload heavy builds to a bigger machine or a GPU box
- **macOS toolchains** — run `xcodebuild`, `swift`, `xcrun` from a Linux container
- **Windows toolchains** — run `dotnet`, `msbuild` from a Linux container
- **No room for extra containers** — one SSH host replaces five builders

SSH works with **both** the `docker` and `compose` runners, because it is only environment variables plus one bind mount.

## Profile configuration

```yaml
ssh:
  host: build.example.com            # required
  port: 22                           # optional, default 22, 1–65535
  user: agent                        # optional, default "agent"
  key_path: ${HOME}/.ssh/heretic_ed25519   # optional, MUST be absolute
  host_cwd: /home/agent/projects/myapp     # optional working directory on the remote host
```

At start-up the runner:

1. injects the SSH parameters as environment variables,
2. bind-mounts `key_path` read-only at `/home/agent/.ssh/id_rsa`,
3. lets the image entrypoint detect `SSH_HOST` and generate wrappers for every toolchain command missing from the image.

| Field | Type | Default | Required | Notes |
|-------|------|---------|----------|-------|
| `ssh.host` | string | — | yes | validated non-empty |
| `ssh.port` | number | `22` | no | must be 1–65535 |
| `ssh.user` | string | `agent` | no | |
| `ssh.key_path` | string | — | no | path on the **host**; must be **absolute after interpolation** |
| `ssh.host_cwd` | string | the container's current directory | no | `${VAR}` supported |

> **Warning: key_path must be absolute — use ${HOME}, not ~**
>
> `~/.ssh/id_rsa` fails validation with `ssh.key_path must be an absolute path: ~/.ssh/id_rsa`. Write `${HOME}/.ssh/id_rsa`, which is interpolated to an absolute path before validation.

### Injected environment variables

| Variable | Value |
|----------|-------|
| `SSH_HOST` | `ssh.host` |
| `SSH_PORT` | `ssh.port`, default `22` |
| `SSH_USER` | `ssh.user`, default `agent` |
| `SSH_KEY_PATH` | the **host** path from `ssh.key_path` |
| `SSH_HOST_CWD` | `ssh.host_cwd` |

> **Note: How the key is actually found**
>
> The key file is mounted at `/home/agent/.ssh/id_rsa`, while `SSH_KEY_PATH` carries the host-side path. `ssh-exec` adds `-i "$SSH_KEY_PATH"` only when that path exists **inside** the container; otherwise it omits `-i` and SSH falls back to the default identity — which is exactly the mounted `~/.ssh/id_rsa`. Both routes end at the same key, as long as `HOME` is `/home/agent` (which is what root mode preserves).

## How `ssh-exec` works

`/opt/sidecar/ssh-exec` is a small shell script. For each invocation it:

1. reads `SSH_HOST` (required — `ssh-exec: SSH_HOST is not set` otherwise), `SSH_PORT`, `SSH_USER`, `SSH_KEY_PATH`, `SSH_HOST_CWD`
2. shell-escapes every argument
3. connects with host-key checking disabled
4. runs `cd <host_cwd> && <command>` on the remote host
5. streams stdout and stderr back and propagates the exit code

```bash
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
    -o LogLevel=ERROR -p 22 -i /home/agent/.ssh/id_rsa \
    agent@build.example.com \
    "cd /home/agent/projects/myapp && npm install"
```

| Option | Value | Why |
|--------|-------|-----|
| `StrictHostKeyChecking` | `no` | containers are ephemeral and carry no `known_hosts` |
| `UserKnownHostsFile` | `/dev/null` | nothing to pollute in a disposable container |
| `LogLevel` | `ERROR` | suppress banners so tool output stays clean |

> **Warning: Host-key checking is disabled**
>
> That makes the connection vulnerable to man-in-the-middle attacks on untrusted networks. Use it for hosts you control. For stricter setups, ship your own `ssh-exec` in a custom image with a `known_hosts` file — start from `heretic-cli image generate --format ssh-exec`.

> **Warning: Nothing is synced for you**
>
> The remote host must already contain the same source tree at `host_cwd`. SSH executes commands there; it does not copy your workspace. If the trees diverge, builds succeed against the wrong code.

## Key management

```bash
# dedicated key, no passphrase (the container cannot answer a prompt)
ssh-keygen -t ed25519 -f ~/.ssh/heretic_ed25519 -N "" -C "heretic-agent"
ssh-copy-id -i ~/.ssh/heretic_ed25519.pub user@build.example.com

chmod 600 ~/.ssh/heretic_ed25519
chmod 700 ~/.ssh
```

```yaml
ssh:
  host: build.example.com
  user: myuser
  key_path: ${HOME}/.ssh/heretic_ed25519
```

The key is mounted read-only. In CI, keep the path in a secret script:

```yaml
secrets:
  SSH_PRIVATE_KEY_PATH: ~/.heretic/get-ssh-key-path.sh
ssh:
  host: build.example.com
  key_path: ${SSH_PRIVATE_KEY_PATH}
```

```bash
#!/bin/bash
echo "/home/youruser/.ssh/heretic_ed25519"
```

## Example: reach tools on your own machine

The agent runs in Docker; SSH gives it your host's native toolchain.

```bash
# host preparation
# macOS: System Settings → General → Sharing → Remote Login
# Linux: sudo systemctl enable --now sshd

ssh-keygen -t ed25519 -f ~/.ssh/heretic_ed25519 -N "" -C "heretic-agent"
ssh-copy-id -i ~/.ssh/heretic_ed25519.pub "$USER@localhost"

# Linux: find the bridge gateway address
docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
```

```yaml
name: claude-ssh-host
image: giglabo/claude-heretic:latest
runner: docker
agent_type: claude
provider: anthropic

secrets:
  ANTHROPIC_API_KEY: ~/.heretic/get-anthropic-key.sh

volumes:
  - { source: "${CWD}", target: /workspace }
workdir: /workspace

ssh:
  host: host.docker.internal    # macOS / Windows; on Linux use the bridge gateway
  port: 22
  user: youruser
  key_path: ${HOME}/.ssh/heretic_ed25519
  host_cwd: ${CWD}              # the same project directory on the host
```

> **Note: Linux hosts**
>
> `host.docker.internal` may not resolve on Linux. Use the bridge gateway address (often `172.17.0.1`), or add a host entry through `extra` / your compose file with `host-gateway`.

```bash
heretic-cli run claude-ssh-host
```

Inside the container any wrapped command runs on the host:

```bash
xcrun --version     # macOS host
dotnet --version    # Windows host
go build ./...      # host Go toolchain
```

## Example: remote build server

```yaml
name: claude-remote-build
image: giglabo/claude-heretic:latest
runner: docker
agent_type: claude
provider: anthropic

volumes:
  - { source: "${CWD}", target: /workspace }
workdir: /workspace

ssh:
  host: build.example.com
  port: 22
  user: ci
  key_path: ${HOME}/.ssh/build-server-key
  host_cwd: /home/ci/projects/myapp
```

Per-project override — `ssh` merges key by key, so change only what differs:

```yaml
# .heretic/cli/claude-remote-build.yaml
extends: claude-remote-build
ssh:
  host_cwd: /home/ci/projects/different-project
```

## Combining SSH with build sidecars

Resolution order inside the container:

```
native binary on PATH   >   HTTP sidecar for that runtime   >   SSH host
```

So SSH acts as the catch-all for runtimes with no sidecar:

```yaml
runner: docker

tool_backends:
  sidecars:
    - runtime: python
      image: heretic-builder-python:latest

ssh:
  host: build.example.com
  user: ci
  key_path: ${HOME}/.ssh/build-key
  host_cwd: /home/ci/myapp
```

Result: `python3`, `pytest`, `poetry` go to the Python builder; `go`, `cargo`, `npm` go to the SSH host.

> **Warning: Heretic warns about this combination**
>
> Configuring both backends logs a warning, because a single build can then straddle two filesystems — the builder's bind mount and the remote host's tree. Prefer one backend per project unless the split is deliberate, as above.

## Debugging

```bash
# inside the agent container
env | grep SSH_
ls -la /home/agent/.ssh/id_rsa            # expect -r-------- (mounted read-only)
ls /opt/sidecar/wrappers/                 # which commands got wrapped
cat /opt/sidecar/wrappers/go              # exec /opt/sidecar/ssh-exec "go" "$@"

/opt/sidecar/ssh-exec npm --version       # test the backend directly

ssh -o StrictHostKeyChecking=no -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
    "echo connected && uname -a"
```

On the host side, verbose CLI logging shows the resolved SSH block and the generated mounts:

```bash
heretic-cli -V run claude-ssh-host
heretic-cli local-validate claude-ssh-host    # full resolved config
```

| Symptom | Cause |
|---------|-------|
| `Config validation failed: ssh.host must be non-empty` | the `ssh` block exists but `host` is empty |
| `ssh.key_path must be an absolute path: …` | you used `~` — switch to `${HOME}` |
| `Permission denied (publickey)` | the public key is not in the remote `authorized_keys`, or the key has a passphrase |
| wrappers exist but commands run locally | the command exists natively in the image, so it was never wrapped |
| no wrappers at all | the image has no baked entrypoint — rebuild with `heretic-cli image build`, or run with `--root` |
| builds use the wrong code | the remote `host_cwd` tree is out of date — nothing is synced automatically |

## Next Steps

- [Sidecars](https://giglabo.com/heretic/docs/heretic-cli/configuration/sidecars) — the HTTP builder backend
- [Runners](https://giglabo.com/heretic/docs/heretic-cli/configuration/runners) — docker, compose, custom
- [Secrets](https://giglabo.com/heretic/docs/heretic-cli/configuration/secrets) — key paths and tokens
- [Local Overrides](https://giglabo.com/heretic/docs/heretic-cli/configuration/local-overrides) — per-project SSH targets

## Related

- HTML version of this page: https://giglabo.com/heretic/docs/heretic-cli/configuration/ssh
- Site map for agents: https://giglabo.com/llms.txt
