Jump Hosts
runspec jump runs a runspec tool on a remote machine over SSH+MCP. The
remote configuration lives in [config.jump-hosts] in your local
runspec.toml; the binary itself does the SSH handshake, speaks MCP
JSON-RPC over stdin/stdout to a runspec serve on the remote, and streams
the result back.
Replaces the removed registry service
Earlier versions of runspec shipped a separate runspec-registry HTTP
service for tool discovery via heartbeat polling. That was removed in
0.7.0; the package on PyPI is archived. [config.jump-hosts] is the
current — and only — remote-execution model.
The model
agent / terminal jump host
│ │
│ runspec jump prod-app deploy │
│ ── parses [config.jump-hosts.prod-app] │
│ │
│ ssh -o BatchMode=yes prod-app │
│ runspec serve │
│ ──────────────────────────────────────► │
│ │
│ ◄──── MCP JSON-RPC over stdin/stdout ──► │
│ │
│ tools/call deploy --env prod │
│ ──────────────────────────────────────► runspec serve
│ │ spawns
│ ▼
│ deploy --env prod
│ ◄──── stdout / stderr streamed back ──┤
│ exit code propagated │
The local runspec.toml only needs to know how to reach the jump host. The
runnables themselves are defined and installed on the remote.
Configuring a jump host
[config.jump-hosts.<alias>] declares a target. The alias is what you type:
runspec jump prod-app looks up [config.jump-hosts.prod-app].
Every field is optional. The minimal config is just the section header — the
alias becomes the SSH host name and everything else comes from
~/.ssh/config.
| Field | Type | Default | Env fallback | Description |
|---|---|---|---|---|
host |
string | the alias | — | Hostname or IP. Usually matches a Host entry in ~/.ssh/config. |
bin |
string | "runspec" |
RUNSPEC_JUMP_BIN |
Path to the runspec binary on the remote. Basename must be runspec (or runspec.exe). |
user |
string | — | — | SSH user (becomes user@host). |
port |
int | 22 |
— | SSH port. Emitted as -p N only when non-default. |
ssh-key |
string | — | — | Path to private key (becomes -i <path>). |
use-ssh-config |
bool | true |
— | When false, ssh runs with -F /dev/null and ignores ~/.ssh/config entirely. |
ssh-options |
array of string | [] |
— | Extra -o KEY=VALUE options passed to ssh. Each item becomes one -o flag. |
Four typical setups
The cleanest setup. Put per-host config in ~/.ssh/config, give the
alias the same name as the Host entry:
[config.jump-hosts.prod-app]
# everything (user, port, key, ProxyJump) comes from ssh-config
Useful when the alias is a project-readable name but ssh-config doesn't have it:
[config.jump-hosts.shorty]
host = "actual.hostname.internal.example.com"
[config.jump-hosts.ci-target]
host = "10.0.0.5"
user = "deploy"
ssh-key = "/secrets/deploy_key"
use-ssh-config = false
port = 2222
For anything ssh-config supports but the TOML doesn't have a dedicated field for:
[config.jump-hosts.bastion-fronted]
host = "internal.example.com"
ssh-options = [
"ProxyJump=bastion.example.com",
"ConnectTimeout=10",
"ServerAliveInterval=30",
]
Listing and invoking
List configured aliases
runspec jump --list-jump-hosts
runspec jump --list-jump-hosts --format json
Text output shows the effective bin value (the field's default of
runspec, the explicit override, or $RUNSPEC_JUMP_BIN):
Configured jump hosts (/home/user/project/runspec.toml):
prod-app host: prod-app bin: runspec
shorty host: actual.hostname.example.com bin: runspec
ci-target host: 10.0.0.5 (user: deploy) bin: runspec
List tools on a host
runspec jump prod-app
SSHes to the host, speaks MCP, and lists the tools the remote exposes — exactly what an agent connected to that host would see.
Run a tool
runspec jump prod-app deploy -- --env production
runspec jump ci-target run-migrations -- --schema users --dry-run
Everything after -- is forwarded to the tool on the remote (the
positional rest-type arg). The local CLI parses out the alias and tool;
everything else is the remote tool's argv.
stderr from the remote is streamed live, so when something goes wrong on the other side you see it in real time — not buried in a log file you have to fetch.
SSH argv construction
runspec jump shells out to the system ssh binary. The argv order
matters because OpenSSH uses first-value-wins for command-line options:
ssh -o BatchMode=yes ← always; locked because stdin is JSON-RPC
[-F /dev/null] ← when use-ssh-config = false
[-p PORT] ← when port ≠ 22
[-i SSH-KEY] ← when ssh-key is set
[-o OPT]... ← each ssh-options item
[user@]host bin serve
BatchMode=yes is locked because runspec jump pipes JSON-RPC over
stdin/stdout — interactive prompts would corrupt the protocol. Use
ssh-agent for keys that need a passphrase.
Explicit fields (port, ssh-key) appear in argv before ssh-options, so
on conflict the explicit field wins. If you specify both port = 2222 and
ssh-options = ["Port=99"], the connection uses port 2222.
Restricting where a runnable can run
The runnable-side counterpart to [config.jump-hosts] is the hosts field
on a runnable. runspec serve checks the current hostname at startup;
tools that don't match are excluded from the MCP tool list:
[parse-app-logs]
description = "Parse and summarise application logs"
autonomy = "confirm"
hosts = ["logserver-01", "logserver-02"]
On any other host, parse-app-logs is invisible. The hosts field protects
against accidental invocation on the wrong machine — it's not a security
boundary, just an availability filter.
Privilege escalation
When a tool needs to run as a different user on the remote, use run_as
together with become_method / become_flags. These are resolved by
runspec serve on the remote against the local hostname before the tool
runs.
Four run_as shapes
1. Simple string — same user on all hosts:
[deploy]
run_as = "oracle"
2. Environment variable — resolved at startup, useful when config management (Ansible group_vars, Salt pillars, etc.) supplies the value:
[deploy]
run_as = "$ORACLE_RUN_AS"
3. Per-host exact match — empty string means no escalation on that host:
[deploy]
run_as.default = "oracle"
run_as.hosts."special-box-01" = "dba"
run_as.hosts."special-box-02" = "" # no privilege escalation here
4. Pattern matching (regex, re.fullmatch, top-to-bottom, first match wins):
[deploy]
run_as.default = "oracle"
run_as.patterns."[lg]pexp[0-9]*" = "orasvc"
run_as.patterns."prod[0-9]*" = "produser"
Forms 3 and 4 can be combined. Resolution order: exact hosts match →
first matching patterns entry → default → no escalation.
Invalid regex patterns cause runspec serve (and runspec local) to exit
with a clear error at startup.
Become methods
[deploy]
run_as = "oracle"
become_method = "sudo" # default — also: su, pbrun, dzdo
become_flags = "-H" # passed through to the become method
| Method | Command constructed |
|---|---|
sudo |
sudo {flags} -u {user} {command} {args} |
su |
su {flags} -c "{command} {args}" {user} |
pbrun |
pbrun {flags} -u {user} {command} {args} |
dzdo |
dzdo {flags} -u {user} {command} {args} |
su uses a distinct -c "..." wrapping because it doesn't accept -u.
When the runnable declares both env args and run_as, the command is
prefixed with env KEY=val ... after the become so variables apply in the
target user's process context. This avoids requiring sudo -E /
env_keep in sudoers and needs no sshd_config changes.
Remote runspec binary resolution
The bin field is locked to executables named runspec (or
runspec.exe). The resolution cascade is:
- Explicit
binfield in[config.jump-hosts.<alias>] RUNSPEC_JUMP_BINenvironment variable- The remote's
PATH
[config.jump-hosts.prod-app]
bin = "/opt/runspec/bin/runspec" # pinned absolute path
If the resolved path doesn't exist on the remote (or its basename isn't
runspec), runspec jump fails before invoking SSH with a message that
distinguishes between "the bin field was explicit" and "we tried PATH".
Trust model
runspec jump ultimately executes whatever binary lives at the resolved
bin path. The format provides three forms of intent enforcement, but no
cryptographic protection:
- Basename is locked to
runspec/runspec.exe. Accidental redirection (bin = "/usr/bin/cat") is rejected before SSH runs. - MCP handshake required. Any process that doesn't speak JSON-RPC over
stdio fails the
initializeexchange and the call aborts. stderris streamed live. Anything the remote writes to stderr — including pre-exec output from a wrapper script — appears in the user's terminal in real time, not hidden behind a log file.
These cover accidents (typos, stale values, wrong paths), not adversaries.
The format cannot distinguish a real runspec binary from a wrapper
named runspec that runs malicious code and then execs the real binary.
If your threat model includes that, defences live above runspec:
- Treat
runspec.tomllike shell config. Audit changes via PR review; don't accept TOMLs from untrusted sources without reading them. - Lock down remote filesystem permissions. The remote should not be writable by anyone who isn't trusted to run code as you.
- Pin
binto absolute paths under controlled directories. Prefer/opt/...or/usr/...paths managed by configuration management over user-writable locations like/tmpor$HOMEsubdirectories. Abin = "/tmp/foo/runspec"in a checked-in TOML is a red flag worth questioning.
Cross-platform notes
runspec jump invokes the system ssh binary. This works identically on:
- Linux / macOS — OpenSSH is the system default.
- Windows 10 (1809+) / Windows 11 — built-in OpenSSH Client at
C:\Windows\System32\OpenSSH\ssh.exe, onPATHby default. The ssh-config lives atC:\Users\<you>\.ssh\config.
If Get-Command ssh doesn't find anything on a Windows machine, the
OpenSSH Client capability is disabled. Enable it (admin):
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
PuTTY, plink, and MobaXterm can coexist on the same machine but are not used by runspec — the protocol-level requirement is OpenSSH semantics.
Where this fits in the changelog
| Change | Version |
|---|---|
runspec-registry removed; replaced by [config.jump-hosts] model |
0.7.0 |
--list-jump-hosts flag, locked bin basename, RUNSPEC_CONFIG forwarding |
0.9.0 |