The v2 client (0.2.0) forwards its own stdin and only closes the ssh stdin channel when that
stdin hits EOF. When sic is invoked with an inherited stdin that never closes (a pipe held open
by the caller -- exactly how non-interactive tooling runs it), the channel stays open, and
pumpStdin's `io.Copy(child.stdin, ssh-stdin)` stayed BLOCKED on the read long after the remote
command had already exited. sicd never reaped, ssh never returned, and the whole call hung until
stdin EOF or a timeout. Measured: `sic host true </dev/null` = 3.4s (== raw ssh), bare = 20s hang.
Fix: race the stdin copy against the child's exit (what ssh itself does).
* child exits first -> it did not need the rest of stdin; stop forwarding, abandon the read,
return the child's status. (the hang fix)
* copy ends first (EOF/EPIPE/error) -> its outcome is meaningful; a genuine read error that
truncated the transfer to a still-live child is still a non-zero failure.
* near-simultaneous -> a bounded 50ms window still catches a real truncation, but a genuinely
blocked stdin can no longer re-introduce the hang.
Daemon-only change; the client is untouched, and v1/v2 dual-read is unaffected. New regression
TestPumpStdinReturnsOnChildExitDespiteBlockedStdin (never-EOFing stdin + exited child -> prompt
child status, not a hang). Existing pump tests (read-error->1, EPIPE head-semantics, clean EOF)
unchanged and green. Verified live: bare `sic host true` 20s -> 3.9s; piped/redirected stdin
still forward.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sic
Run a command on a remote host without a shell re-parsing its arguments.
ssh host touch 'a b' creates two files: ssh joins its arguments with spaces
and hands the result to the remote login shell, which splits it again. sic host touch 'a b' creates one file named a b. sic sends the argument vector to a
daemon on the host as length-prefixed fields, and the daemon calls execvp on
it. No shell parses the arguments.
Components
sic— client. Takes argv on the command line, frames it as netstrings, pipes the frame oversshtosicd.sicd— daemon on the target host. Reads netstrings from stdin and runs the command.
Both are single static Go binaries (cmd/sic, cmd/sicd). A Python reference
implementation of the daemon is in gateway/sicd.
Wire format
Each field is a netstring: <length>:<bytes>,. A frame is a mode field, zero or
more argument fields, and an empty terminator:
<mode> <arg>* 0:,
argv = ["touch", "a b"] is 4:exec,5:touch,3:a b,0:,. The length prefix means
no byte needs escaping and no delimiter can collide with the payload.
| mode | selector | behaviour |
|---|---|---|
exec (default) |
4:exec, |
execvp(argv), no shell. Arguments are passed through unchanged. |
sh |
2:sh, |
one field passed to sh -c. Pipes, redirects, and globs work. |
Usage
sic <host> <command> [arg ...]
sic --sh <host> '<shell string>'
sic <host> -- <command> [arg ...] # explicit separator
sic host1 echo hello world
sic host1 touch 'a b' # one file named "a b"
sic host1 echo '$HOME' # literal, no expansion
sic --sh host1 'echo hi | wc -c'
Build
go build -o bin/sic ./cmd/sic
go build -o bin/sicd ./cmd/sicd
Install
Client:
cp bin/sic ~/bin/sic
Daemon on each target host. sicd runs what it is sent, so it must only be
reachable over an authenticated transport. Pin it to a dedicated ssh key so the
target can run nothing else with that key:
scp bin/sicd host:/tmp/sicd
ssh host 'sudo install -m755 /tmp/sicd /usr/local/bin/sicd'
# ~/.ssh/authorized_keys on the target:
# command="sicd",no-port-forwarding,no-pty ssh-ed25519 AAAA...
Do not listen on a TCP or message-bus port.
Test
printf '4:exec,2:id,0:,' | ssh host sicd
sic host echo hello world
See also
docs/design.md— wire format, exec modes, transport and security, prior art.demos/quoting-hell.py— six cases run both ways, plainsshversussic.SKILL.md,SKILL-bg.md— agent skill definitions (foreground and background).