One-shot Exec
cmdop connect exec runs a single shell command on a remote machine,
collects its stdout, stderr, and exit code, and returns. There is no PTY,
no picker, and no expectation of a TTY on either end. It is the workhorse
verb for scripts, CI, agent tools, and quick one-liners.
Syntax
cmdop connect exec <hostname> -- <command...>The -- separator is required: it tells the CLI to stop parsing flags
and pass everything after it verbatim to the remote shell. Without it,
flags meant for the remote command would be eaten by the local CLI.
# Quick examples.
cmdop connect exec vps-audi -- uptime
cmdop connect exec prod-api-1 -- systemctl status nginx
cmdop connect exec mac-studio -- 'cd /tmp && ls -la'
cmdop connect exec vps-bmw -- bash -c 'echo $HOSTNAME && id'The implementation lives in
go/cmd/cmdop/cmds/connect/connect_exec_command.go.
What you get back
By default, exec mirrors the remote command’s stdout and stderr to your local stdout/stderr and exits with the remote exit code:
$ cmdop connect exec prod-api-1 -- systemctl is-active nginx
active
$ echo $?
0If the remote command returns a non-zero exit, so does the local CLI.
This makes cmdop connect exec a drop-in for any place a script would
otherwise call ssh host -- cmd.
JSON mode
Pass --json to get a structured envelope instead of raw streams:
$ cmdop connect exec --json prod-api-1 -- uptime
{
"machine_id": "8f23a4b0-...",
"hostname": "prod-api-1",
"command": ["uptime"],
"exit_code": 0,
"stdout": " 14:32:01 up 12 days, 4:11, 1 user, load average: 0.18, 0.21, 0.19\n",
"stderr": "",
"duration_ms": 142
}--json implies --no-interactive, never prompts, and is the right
choice when a script or agent tool consumes the output.
Timeouts
Exec is timeout-bounded. The default is 30 seconds; override with
--timeout:
# Wait up to 5 minutes for a slow build script.
cmdop connect exec --timeout 5m vps-bmw -- ./scripts/build.shThe agent-tool surface (connecttool operation=exec) accepts
timeout_ms as an integer parameter, with the same 30s default. Server
side, the relay enforces an upper bound — anything beyond a few minutes
should use remote-sessions instead, where you can
poll progress and avoid hitting the unary RPC ceiling.
Exec is for commands that finish. For tailing logs, watching a file, or any open-ended work, open a persistent session — exec will time out and return what stdout has buffered so far.
Recovery for password-protected machines
When the remote machine has a password set, exec uses a two-step recovery
path documented in internal/connect/client/CLAUDE.md §ExecOnce:
- The CLI tries the unary
ExecuteRPC directly. - The server returns
ErrSessionTokenRequiredbecause nox-session-tokenmetadata was attached. - The CLI opens a brief streaming auth (
ConnectTerminal), answers theAuthChallenge, captures thesession_token, and stores it insessionauth.Default()(24h TTL, process-wide). - The CLI retries the unary
Execute— this time it carries the token and succeeds.
Non-password machines pay zero overhead — step 1 succeeds. Password
machines pay one extra streaming open per process lifetime. Inside agent
tools, the cache is shared, so the second cmdop connect exec against
the same machine in the same daemon reuses the cached token.
Streaming output
Exec streams output as it arrives. For commands that print steadily (builds, tests, log dumps), you see progress live, not after the command finishes. The implementation reads from the remote stream and forwards to local stdout/stderr until either:
- The command exits — exec captures the exit code and returns.
- The timeout fires — exec cancels the call and returns whatever output arrived so far, with a non-zero exit code.
In --json mode, the structured response is emitted only after the
command finishes (or times out); intermediate output is buffered into
stdout / stderr strings.
Exec vs interactive attach
cmdop connect exec | cmdop connect | |
|---|---|---|
| TTY allocated | No | Yes (raw mode). |
| Interactive flow | No | Picker → confirm → attach. |
| Best for | Scripts, CI, agent tools, quick one-liners. | Debugging, exploration, full-screen tools. |
Picks up Ctrl-C | Yes — cancels the local call. | Yes — forwards to remote. |
| Default timeout | 30s | None (session lives until disconnect). |
| Output | Streamed; final exit code returned. | Live PTY. |
If you find yourself running cmdop connect exec ... -- bash, you
probably want interactive attach instead.
Exec from agent prompts
The agent-facing surface is the connect tool with operation=exec:
{
"operation": "exec",
"hostname": "prod-api-1",
"command": "tail -n 50 /var/log/nginx/error.log",
"timeout_ms": 30000
}Returns core.Success with {exit_code, stdout, stderr, duration_ms}
on a normal completion, or core.Error on resolve / dial / auth /
timeout failures. See internal/agent/builtin/tools/connecttool/tool.go
for the parameter union and dispatch.
The agent tool description steers the LLM correctly:
Run shell commands on CMDOP-registered machines — relay-based, no keys/IPs needed. Prefer over local shell when the user names another machine. For agent-to-agent conversation (ask/talk/greet the remote LLM) use
ask_agent…
That last clause matters: exec is for shell, not for “ask the remote agent for an opinion”. For agent-to-agent calls, see server-to-server.
Common patterns
Fan-out by hand
For a small number of hosts, a shell loop is simplest:
for host in vps-audi vps-bmw mac-studio; do
echo "=== $host ==="
cmdop connect exec --timeout 10s "$host" -- uptime
doneFor a larger set, prefer the ask_agents agent tool — it parallelizes,
respects per-host and total deadlines, and returns a structured map. See
server-to-server.
Capture and parse JSON
load=$(cmdop connect exec --json prod-api-1 -- uptime | jq -r '.stdout')
echo "$load"Pipe local data in
Exec inherits stdin. Pipe local input to the remote command:
cat local-config.yaml | cmdop connect exec vps-audi -- 'cat > /etc/myapp/config.yaml'Errors and exit codes
| Exit code | Cause |
|---|---|
| 0 | Remote command exited 0. |
<n> | Remote command exited n (verbatim passthrough). |
| 124 | Local timeout fired before the remote command finished. |
| 125 | Resolver / dial / auth error before the command ran. |
| 130 | Local Ctrl-C cancelled the call. |
The --json envelope’s exit_code field always reflects the remote
exit code; client-side errors surface as a structured error object
instead of an envelope.
Related
When you want a real shell, not a one-shot.
Persistent multi-command sessions for long work.
Agent-to-agent calls and structured fan-out.
Full CLI surface, flags, and JSON output spec.