sic: v2 main() — nested targets, boundary-preserved argv, real stdin forwarding

WP8, the last code before deploy. Rewrites the client entrypoint on the v2 wire:

  * target parsing: host[/hop1/hop2...]; hops resolved to incus/docker/pct verbs via
    /etc/sic/hosts.toml (loaded only when hops are present), folded into the frame onion
    by buildChain — verbs[0] is the outermost layer the host sicd enters first.
  * argv is carried as [][]byte end to end and NEVER space-joined; --sh is the sole
    exception (user asked for a shell line -> [sh -c <joined>]).
  * stdin is forwarded AFTER the frame (the v1 client set Stdin to the frame alone, so
    `sic host cat >f <local` wrote a zero-byte file). The copy runs in an abandoned
    goroutine so a stdin-ignoring command on a tty never blocks on a read that never EOFs.
  * ssh -T: the frame is binary; a pty would mangle it.
  * exit code inherited from the far end.

Dead v1 framing (netstring/frameArgv/indexOf) removed — frame.go owns the wire now.
Also corrected the stale runV2 doc comment in sicd (still described the removed 0:,
terminator; it is argc-prefixed since e1f93de).

E2e on boltzmann (ssh localhost + real sicd), all green: touch "a b" -> ONE file;
echo A "" B -> "A  B"; pipe+redirect stdin both forward; exit 7 propagates; a
stdin-ignoring echo does not hang; --sh; and netstring-looking args (3:x, a,b:c)
survive as single arguments.
This commit is contained in:
Claude (noether)
2026-07-23 11:59:26 +02:00
parent e1f93dea7c
commit ebccfa0467
2 changed files with 121 additions and 81 deletions
+116 -77
View File
@@ -1,107 +1,146 @@
// sic — frame argv as netstrings and pipe to sicd on a remote host.
// sic — preserve argv boundaries across ssh (and nested container hops) by framing
// the command as length-delimited netstrings and piping it to sicd on the far end.
//
// Each argument is framed as a netstring and written to sicd's stdin over ssh.
// The netstring framing is done here so callers pass argv on the command line
// and never construct frames by hand.
// The whole point: `sic host touch 'a b'` creates ONE file, never two. No argument is
// ever space-joined or re-split on the way to the remote exec (the one exception is
// --sh, where the user explicitly asked for a shell line).
//
// Usage:
//
// sic [--sh] <host> <command> [<arg> ...]
// sic [--sh] <host> -- <command> [<arg> ...] (explicit separator)
// sic [--sh] <target> [--] <command> [<arg> ...]
//
// Examples:
// A target is a host, optionally followed by container hops separated by '/':
//
// sic host1 echo hello world
// sic host1 touch 'a b' # ONE file, not two
// sic --sh host1 'echo hi | wc -c'
// sic host echo hi # run on the host
// sic host touch 'a b' # ONE file
// sic dcw2/noether cat /etc/os-release # into the 'noether' guest on dcw2
// sic data/ct110/app id # host -> pct ct110 -> docker app (two hops)
// sic --sh host 'echo hi | wc -c' # shell line (space-join is intended here)
//
// Hop runtimes come from /etc/sic/hosts.toml, one stanza per host:
//
// [dcw2]
// nest = ["incus"]
// [data]
// nest = ["pct", "docker"]
package main
import (
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
)
// netstring encodes a UTF-8 string as a netstring (djb format).
func netstring(s string) string {
encoded := []byte(s)
return strconv.Itoa(len(encoded)) + ":" + string(encoded) + ","
func fatal(format string, a ...interface{}) {
fmt.Fprintf(os.Stderr, "sic: "+format+"\n", a...)
os.Exit(1)
}
// frameArgv builds a single netstring command frame from args.
func frameArgv(args []string, mode string) []byte {
var sb strings.Builder
sb.WriteString(netstring(mode))
switch mode {
case "exec":
for _, a := range args {
sb.WriteString(netstring(a))
}
case "sh":
sb.WriteString(netstring(strings.Join(args, " ")))
func usage() {
fmt.Fprintln(os.Stderr, "Usage: sic [--sh] <target> [--] <command> [<arg> ...]")
os.Exit(1)
}
// loadNest returns the runtime nest for a host from /etc/sic/hosts.toml. Only called when
// the target actually has hops — a bare host never needs the config file.
func loadNest(host string) ([]string, error) {
const path = "/etc/sic/hosts.toml"
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("target has hops but %s is unreadable: %w", path, err)
}
sb.WriteString("0:,")
return []byte(sb.String())
cfg, err := parseHostsConfig(string(data))
if err != nil {
return nil, err
}
nest, ok := cfg[host]
if !ok {
return nil, fmt.Errorf("no [%s] stanza in %s (needed to resolve hops)", host, path)
}
return nest, nil
}
func main() {
argv := os.Args[1:]
args := os.Args[1:]
// Parse flags
mode := "exec"
for len(argv) > 0 && strings.HasPrefix(argv[0], "--") {
switch argv[0] {
case "--sh":
mode = "sh"
case "--":
argv = argv[1:]
goto parseArgs
}
argv = argv[1:]
// --sh is the only leading flag. Everything after the target is the command.
shMode := false
if len(args) > 0 && args[0] == "--sh" {
shMode = true
args = args[1:]
}
if len(args) < 1 {
usage()
}
parseArgs:
if len(argv) == 0 {
fmt.Fprintln(os.Stderr, "Usage: sic [--sh] <host> <command> [<arg> ...]")
os.Exit(1)
target := args[0]
rest := args[1:]
// Optional explicit separator so a command whose first token looks like a flag survives.
if len(rest) > 0 && rest[0] == "--" {
rest = rest[1:]
}
if len(rest) == 0 {
fatal("missing command")
}
// Locate host and command
var host string
var command []string
if idx := indexOf(argv, "--"); idx >= 0 {
host = argv[0]
command = argv[idx+1:]
} else if len(argv) >= 2 {
host = argv[0]
command = argv[1:]
// Build the command argv, boundary-preserved. --sh is the sole place a space-join is
// correct: the user asked for a shell line, not an argv.
var cmdArgv [][]byte
if shMode {
cmdArgv = [][]byte{[]byte("sh"), []byte("-c"), []byte(strings.Join(rest, " "))}
} else {
fmt.Fprintln(os.Stderr, "sic: missing command arguments")
os.Exit(1)
}
frameData := frameArgv(command, mode)
sshCmd := exec.Command("ssh", "-o", "StrictHostKeyChecking=no", host, "sicd")
sshCmd.Stdin = strings.NewReader(string(frameData))
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
if err := sshCmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
fmt.Fprintf(os.Stderr, "sic: %v\n", err)
os.Exit(1)
}
}
func indexOf(slice []string, s string) int {
for i, v := range slice {
if v == s {
return i
cmdArgv = make([][]byte, len(rest))
for i, a := range rest {
cmdArgv[i] = []byte(a)
}
}
return -1
host, hops := parseTarget(target)
var verbs []string
if len(hops) > 0 {
nest, err := loadNest(host)
if err != nil {
fatal("%v", err)
}
verbs, err = resolveVerbs(hops, nest)
if err != nil {
fatal("%v", err)
}
}
frame := buildChain(verbs, cmdArgv, nil)
// ssh -T: never a pty — the frame is binary and a pty would mangle it (CR/LF, echo).
cmd := exec.Command("ssh", "-T", "-o", "StrictHostKeyChecking=no", host, "sicd")
stdin, err := cmd.StdinPipe()
if err != nil {
fatal("stdin pipe: %v", err)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
fatal("ssh: %v", err)
}
// Write the onion, THEN forward our own stdin (the v1 client set Stdin to the frame alone
// and dropped everything after — so `sic host 'cat >f' <local` wrote a zero-byte file).
// The copy runs in a goroutine we abandon on exit: a command that ignores stdin
// (`sic host echo hi` on a tty) must not block on a tty read that never EOFs.
if _, err := stdin.Write(frame); err != nil {
fatal("write frame: %v", err)
}
go func() {
io.Copy(stdin, os.Stdin)
stdin.Close()
}()
if err := cmd.Wait(); err != nil {
if ee, ok := err.(*exec.ExitError); ok {
os.Exit(ee.ExitCode())
}
fatal("ssh: %v", err)
}
}
+5 -4
View File
@@ -197,10 +197,11 @@ func runV2() {
os.Exit(1)
}
// v2 content = argv netstrings + the empty-netstring terminator 0:, + payload. Length-framed
// argv PRESERVES argument boundaries (sic's founding guarantee: `touch 'a b'` is ONE file)
// and lets ANY byte appear in an argument — unlike the old space-split. Everything after the
// terminator is the payload (a nested frame for the next hop, or empty).
// v2 content = netstring(argc) + argc argv netstrings + payload. Length-framed argv PRESERVES
// argument boundaries (sic's founding guarantee: `touch 'a b'` is ONE file) and lets ANY byte
// appear in an argument — unlike the old space-split. The explicit argc (not an empty-netstring
// terminator) makes an empty "" argument representable (reviewer 964 #1); everything after the
// argc argv netstrings is the payload (a nested frame for the next hop, or empty).
argv, payload, ok := parseV2Content(content)
if !ok {
fmt.Fprintf(os.Stderr, "sicd: invalid v2 content (argv framing)\n")