SSH Host Access
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,xcrunfrom a Linux container - Windows toolchains — run
dotnet,msbuildfrom 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
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:
- injects the SSH parameters as environment variables,
- bind-mounts
key_pathread-only at/home/agent/.ssh/id_rsa, - lets the image entrypoint detect
SSH_HOSTand 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 |
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 |
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:
- reads
SSH_HOST(required —ssh-exec: SSH_HOST is not setotherwise),SSH_PORT,SSH_USER,SSH_KEY_PATH,SSH_HOST_CWD - shell-escapes every argument
- connects with host-key checking disabled
- runs
cd <host_cwd> && <command>on the remote host - streams stdout and stderr back and propagates the exit code
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -p 22 -i /home/agent/.ssh/id_rsa \
[email protected] \
"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 |
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.
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
# 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 [email protected]
chmod 600 ~/.ssh/heretic_ed25519
chmod 700 ~/.ssh
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:
secrets:
SSH_PRIVATE_KEY_PATH: ~/.heretic/get-ssh-key-path.sh
ssh:
host: build.example.com
key_path: ${SSH_PRIVATE_KEY_PATH}
#!/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.
# 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}}'
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
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.
heretic-cli run claude-ssh-host
Inside the container any wrapped command runs on the host:
xcrun --version # macOS host
dotnet --version # Windows host
go build ./... # host Go toolchain
Example: remote build server
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:
# .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:
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.
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
# 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:
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 — the HTTP builder backend
- Runners — docker, compose, custom
- Secrets — key paths and tokens
- Local Overrides — per-project SSH targets