Run commands on a paired OpenClaw node from the Gateway. Node protocol when connected, SSH when not. Setup and the security contract live in hybrid-gateway; this skill assumes Steps 1 to 5 there already pass.
On the Gateway: openclaw, ssh, scp, rsync. On the node: the OpenClaw node host service (paired) and, for the fallback only, an SSH server with a dedicated non-root user and key-only login.
On the node: create a dedicated non-root user for the Gateway, allow key-only login for it, keep sshd on the Tailscale interface. On the Gateway:
bash
ssh-keygen -t ed25519 -f ~/.ssh/id_node -N ""
ssh-copy-id -i ~/.ssh/id_node.pub <node-user>@<node-tailscale-ip> # compare the host-key fingerprint with the node's own `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`
Add to ~/.ssh/config on the Gateway:
text
Host <node-alias>
HostName <node-tailscale-ip>
User <node-user>
IdentityFile ~/.ssh/id_node
IdentitiesOnly yes
Never set StrictHostKeyChecking no, never reuse a personal key, never give the node user sudo. Test: ssh <node-alias> -- true.
NeedTransportRun an allowlisted commandexec host=node (node protocol)Full login shell (brew, nvm, pyenv)SSHTransfer filesSSH ( scp / rsync)Node disconnectedSSHLong-running tasknot by default; see Constraints
Routes through the Gateway; no SSH keys involved. The node enforces its own allowlist ( security=allowlist, ask=on-miss): an unlisted command raises an approval instead of running.
tool
exec host=node command="/usr/bin/uname -a"
Use absolute paths. The node's PATH is fixed at service start ( openclaw nodes describe --node <name> prints it), so package-manager tools need their full path:
tool
exec host=node command="/opt/homebrew/bin/python3 --version"
Manage the allowlist from the Gateway, one binary per pattern, never a shell:
bash
openclaw approvals get --node "<node-name>"
openclaw approvals allowlist add --agent main --node "<node-name>" "/opt/homebrew/bin/ollama"
openclaw approvals pending
Direct probe without an agent turn:
bash
openclaw nodes invoke --node "<node-name>" --command system.which --params '{"bins":["node"]}' --json
Use the alias from the setup section. Test with a harmless command first:
bash
ssh <node-alias> -- true
ssh <node-alias> "source ~/.profile 2>/dev/null; node --version"
Anything with JSON, quotes, or $ goes in a script, not inline (zsh and bash quote differently):
bash
ssh <node-alias> bash -s < job.sh
bash
scp file.txt <node-alias>:/tmp/
scp <node-alias>:/tmp/result.txt ./
rsync -avn ./data/ <node-alias>:~/data/ # preview
rsync -av ./data/ <node-alias>:~/data/ # apply after reading the preview
No --delete by default. Never disable host-key checking to make a transfer work.
bash
openclaw nodes status
openclaw nodes describe --node "<node-name>" # caps, commands, PATH, pending reapproval
openclaw nodes status on the Gateway.paired · disconnected: ssh <node-alias> -- true. Reachable means the node service died or the machine slept; unreachable means the route (Tailscale, Wi-Fi, power).ssh <node-alias> "openclaw node restart" (LaunchAgent on macOS, systemd user unit on Linux), then openclaw nodes status.reapproval pending after an upgrade: openclaw nodes describe shows the new commands. Existing caps still work. Approve the exact id with openclaw nodes approve <request-id> only if the workflow needs the new surface.403 Proxy client attribution is required: the Gateway's gateway.trustedProxies is wider than its real proxies. Follow the hybrid-gateway Step 1 guidance: keep only the same-host Serve proxy ( 127.0.0.1) and any reverse proxy you actually run, and never the Tailscale range.OpenClaw 2026.9.3 Gateway (Ubuntu 24.04) and macOS 26 node.