3 Commits

Author SHA1 Message Date
Markus Fritsche ef1d6b517d README: document nesting — the nested-container-hop feature (V2)
The README covered only single-host use; the differentiator from a shell helper
— reaching into containers several hops deep with the argv frame preserved at
every layer — was undocumented. Add a Nesting section (host/guest/svc syntax,
/etc/sic/hosts.toml nest stanzas, and the quoting-hell contrast with the ssh
equivalent), surface it in the intro line, and link docs/design-nested.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
2026-07-24 09:22:24 +02:00
Claude (noether) 62ed5a1c08 sicd: reap on child-exit instead of blocking on stdin (fixes the v2 stdin hang)
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>
2026-07-23 19:26:10 +02:00
Claude (noether) 20a6608f0e docs+examples: de-identify — generic hosts, no personal/model/internal names
Public repo hygiene: scrub local identifiers from the design doc and the usage
examples/tests. docs/design-nested.md loses the hostname, the maintainer name, a
model name, and internal system/agent names (the rejected shared-store router is
now described generically, not by product name); its stale wire line is corrected
to netstring(argc) while there. Usage comments and test fixtures switch real fleet
hostnames/container names to host1/host2/app/db. No behaviour change; all tests green.
2026-07-23 13:17:06 +02:00
9 changed files with 153 additions and 60 deletions
+35 -1
View File
@@ -1,6 +1,7 @@
# sic
Run a command on a remote host without a shell re-parsing its arguments.
Run a command on a remote host — or inside a container several hops deep —
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
@@ -50,6 +51,38 @@ sic host1 echo '$HOME' # literal, no expansion
sic --sh host1 'echo hi | wc -c'
```
## Nesting
The frame survives being wrapped again, so `sic` reaches *into* containers with no
shell anywhere in the path, at any depth. A target is a host followed by container
hops separated by `/`:
```
sic host1/app cat /etc/os-release # into the 'app' guest on host1
sic host2/ct/svc touch 'a b' # host2 -> ct -> svc, ONE file, three layers deep
```
Each host's hop runtimes come from `/etc/sic/hosts.toml`, one stanza per host:
```toml
[host1]
nest = ["incus"] # host1/app -> incus exec app -- <argv>
[host2]
nest = ["pct", "docker"] # host2/ct/svc -> pct exec ct -- docker exec svc -- <argv>
```
This is what separates `sic` from a shell helper script. The `ssh` equivalent —
```
ssh host2 "pct exec ct -- docker exec svc -- touch \"a b\""
```
nests quotes inside quotes inside quotes, and every layer re-splits the arguments; a
body containing a space, `$()`, a pipe, or a newline breaks a different layer each time.
`sic` frames the argv once and re-frames it at each hop, so `touch 'a b'` is one file
whether it runs on the host or three containers deep. No escaping, at any level.
## Build
```
@@ -88,5 +121,6 @@ sic host echo hello world
## See also
- `docs/design.md` — wire format, exec modes, transport and security, prior art.
- `docs/design-nested.md` — how nested container hops resolve.
- `demos/quoting-hell.py` — six cases run both ways, plain `ssh` versus `sic`.
- `SKILL.md`, `SKILL-bg.md` — agent skill definitions (foreground and background).
+14 -12
View File
@@ -6,33 +6,35 @@ import (
)
// WP7: parseHostsConfig parses the minimal hosts.toml subset (no external dep — stdlib only):
// # comment
// [<host>]
// nest = ["incus", "docker"]
//
// # comment
// [<host>]
// nest = ["incus", "docker"]
//
// into map[host] -> ordered runtime types. Comments and blank lines are ignored; an empty nest
// list is a present host with zero hops.
func TestParseHostsConfig(t *testing.T) {
data := "# fleet nest config\n" +
"[boltzmann]\n" +
"[host1]\n" +
"nest = [\"incus\", \"docker\"]\n" +
"\n" +
"[data]\n" +
"[host2]\n" +
"nest = [\"pct\"]\n" +
"\n" +
"[bosch]\n" +
"[host3]\n" +
"nest = []\n"
cfg, err := parseHostsConfig(data)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !reflect.DeepEqual(cfg["boltzmann"], []string{"incus", "docker"}) {
t.Fatalf("boltzmann nest = %#v, want [incus docker]", cfg["boltzmann"])
if !reflect.DeepEqual(cfg["host1"], []string{"incus", "docker"}) {
t.Fatalf("host1 nest = %#v, want [incus docker]", cfg["host1"])
}
if !reflect.DeepEqual(cfg["data"], []string{"pct"}) {
t.Fatalf("data nest = %#v, want [pct]", cfg["data"])
if !reflect.DeepEqual(cfg["host2"], []string{"pct"}) {
t.Fatalf("host2 nest = %#v, want [pct]", cfg["host2"])
}
b, ok := cfg["bosch"]
b, ok := cfg["host3"]
if !ok || len(b) != 0 {
t.Fatalf("bosch must be present with an empty nest; got %#v ok=%v", b, ok)
t.Fatalf("host3 must be present with an empty nest; got %#v ok=%v", b, ok)
}
}
+4 -4
View File
@@ -13,15 +13,15 @@
//
// 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 host1/app cat /etc/os-release # into the 'app' guest on host1
// sic host2/ct/svc id # host -> pct ct -> docker svc (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]
// [host1]
// nest = ["incus"]
// [data]
// [host2]
// nest = ["pct", "docker"]
package main
+3 -3
View File
@@ -14,9 +14,9 @@ func TestParseTarget(t *testing.T) {
host string
hops []string
}{
{"boltzmann", "boltzmann", nil},
{"boltzmann/memory", "boltzmann", []string{"memory"}},
{"boltzmann/memory/stash-stash-1", "boltzmann", []string{"memory", "stash-stash-1"}},
{"host1", "host1", nil},
{"host1/app", "host1", []string{"app"}},
{"host1/app/db", "host1", []string{"app", "db"}},
}
for _, tc := range cases {
host, hops := parseTarget(tc.in)
+2 -2
View File
@@ -9,11 +9,11 @@ import (
// Too many hops for the declared nest depth is a hard error (fail-loud); an unknown runtime
// propagates verbFor's error. Zero hops -> zero verbs (a bare host is a single v2 frame).
func TestResolveVerbs(t *testing.T) {
got, err := resolveVerbs([]string{"memory", "stash-stash-1"}, []string{"incus", "docker"})
got, err := resolveVerbs([]string{"app", "db"}, []string{"incus", "docker"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
want := []string{"incus exec memory --", "docker exec stash-stash-1"}
want := []string{"incus exec app --", "docker exec db"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("resolveVerbs = %#v, want %#v", got, want)
}
+2 -2
View File
@@ -10,8 +10,8 @@ func TestVerbFor(t *testing.T) {
cases := []struct {
runtime, name, want string
}{
{"incus", "memory", "incus exec memory --"},
{"docker", "stash-stash-1", "docker exec stash-stash-1"},
{"incus", "app", "incus exec app --"},
{"docker", "db", "docker exec db"},
{"pct", "107", "pct exec 107 --"},
}
for _, tc := range cases {
+41 -7
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"syscall"
"time"
)
func readNetstring(buf []byte) ([]byte, bool) {
@@ -344,14 +345,47 @@ func runV1(r *bufio.Reader) {
// os.read to raise OSError), since no real fd on this platform yields a read error on stdin
// (a pty slave and a socket peer both see a clean kernel EOF on hangup, in Go AND CPython).
func pumpStdin(dst io.WriteCloser, src io.Reader, reap func() int, errOut io.Writer) int {
_, err := io.Copy(dst, src)
dst.Close()
if err != nil && !errors.Is(err, syscall.EPIPE) {
fmt.Fprintf(errOut, "sicd: forward stdin: %v\n", err)
reap()
return 1
// Race the stdin copy against the child's exit. The old code did io.Copy THEN reap, so a
// client that never closes its stdin (holds the ssh channel open with no bytes) kept the
// copy blocked on the READ long after the child had exited — sicd never reaped, ssh never
// returned, the whole call hung until stdin EOF or a timeout. Now: whichever ends first wins.
copyDone := make(chan error, 1)
go func() {
_, err := io.Copy(dst, src)
dst.Close() // a still-reading child sees EOF
copyDone <- err
}()
childExited := make(chan int, 1)
go func() { childExited <- reap() }()
select {
case err := <-copyDone:
// The stdin transfer concluded (EOF / EPIPE / error) — its outcome is meaningful.
if err != nil && !errors.Is(err, syscall.EPIPE) {
// A genuine read error truncated the transfer to a still-live child -> failure,
// regardless of the child's own eventual status (the child cannot tell a truncated
// EOF from a clean one).
fmt.Fprintf(errOut, "sicd: forward stdin: %v\n", err)
<-childExited
return 1
}
return <-childExited
case status := <-childExited:
// The child exited BEFORE the copy finished: it did not need the rest of stdin (e.g. a
// command that ignores stdin, like `true`). Stop forwarding — abandon the (possibly
// blocked) read. This is the hang fix. Still honour a truncation the child DID care
// about if the copy is finishing this very instant, but bound the wait so a genuinely
// blocked stdin cannot re-introduce the hang.
select {
case err := <-copyDone:
if err != nil && !errors.Is(err, syscall.EPIPE) {
fmt.Fprintf(errOut, "sicd: forward stdin: %v\n", err)
return 1
}
case <-time.After(50 * time.Millisecond):
}
return status
}
return reap()
}
func writeAll(w io.Writer, data []byte) error {
+22
View File
@@ -591,6 +591,28 @@ func TestPumpStdinEpipeIsHeadSemanticsReturnsChildStatus(t *testing.T) {
}
}
// blockingReader.Read blocks forever — models a client that holds its stdin open and never
// sends EOF (exactly how the Bash tool invokes sic non-interactively).
type blockingReader struct{}
func (blockingReader) Read([]byte) (int, error) { select {} }
func TestPumpStdinReturnsOnChildExitDespiteBlockedStdin(t *testing.T) {
// Regression for the fleet-wide hang: a never-EOFing stdin must NOT keep sicd blocked in the
// copy after the child has exited. pumpStdin must return the child's status promptly.
dst := &errWriteCloser{}
done := make(chan int, 1)
go func() { done <- pumpStdin(dst, blockingReader{}, func() int { return 7 }, io.Discard) }()
select {
case code := <-done:
if code != 7 {
t.Fatalf("expected the child's status 7, got %d", code)
}
case <-time.After(2 * time.Second):
t.Fatal("pumpStdin hung on a never-EOFing stdin after the child exited (the bug)")
}
}
func TestPumpStdinCleanEofReturnsChildStatus(t *testing.T) {
src := bytes.NewReader([]byte("all of the stdin, cleanly"))
dst := &errWriteCloser{}
+30 -29
View File
@@ -1,9 +1,8 @@
# sic — nested (multi-hop) design & chain-resolution decision
Status: **Accepted** — 2026-07-23, bullpen design session (room msgs 865877;
@architect / @skeptic on deepseek-v4-flash-dspark, initiated by Markus).
This records a decision and its rejected alternatives *for the audit trail* — a future
reader should see that mneme-as-router was considered and killed for cause, not overlooked.
Status: **Accepted** — 2026-07-23. This records a decision and its rejected alternatives
*for the audit trail* — a future reader should see that a shared-store router was considered
and killed for cause, not overlooked.
## Goal
@@ -22,8 +21,8 @@ repeats. There is **no separate `sic-run` binary** — see Decision 3. Construct
and up-front; unrolling is iterative and distributed.
One frame = preamble (`MAGIC 0x00`, then 4-byte big-endian `len32`) + a djb netstring whose
content is `<command> 0x00 <payload>`; the payload of a non-terminal frame is itself a full
frame for the next hop.
content is `netstring(argc)` + argc argv netstrings + `payload`; the payload of a non-terminal
frame is itself a full frame for the next hop.
## Decision 1 — chain resolution: **static per-client config**
@@ -32,40 +31,41 @@ frame for the next hop.
concrete verb + address at each layer. **Fail loud on any mismatch** (unknown host, more hops
than the config declares) — never guess.
### Rejected: mneme as the chain router
### Rejected: a shared memory store as the chain router
Resolving chains by querying the fleet-memory store (mneme) was proposed and **rejected**.
Even with the bootstrap circularity removed (mneme's own address is a flat local setting, so a
lookup is one direct call, not a nested one), it loses on four counts:
Resolving chains by querying a shared, mutable fleet-memory store was proposed and **rejected**.
Even with the bootstrap circularity removed (the store's own address is a flat local setting, so
a lookup is one direct call, not a nested one), it loses on four counts:
- **Trust (dispositive).** mneme is a *mutable shared* store. Any container with write access
poisons one chain entry and every subsequent `sic` call through that chain execs the
attacker's verb on the target host — a supply-chain RCE vector. Static config is
immutable-after-deploy and auditable once.
- **Trust (dispositive).** Such a store is *mutable and shared*. Any writer poisons one chain
entry and every subsequent `sic` call through that chain execs the attacker's verb on the
target host — a supply-chain RCE vector. Static config is immutable-after-deploy and auditable
once.
- **Staleness fails the wrong way.** A moved/renamed container makes the stored chain lie, and
the client *silently* execs into the wrong container. Static config fails loud (connection
refused); mneme turns a hard error into quiet wrong-target execution.
- **Availability.** mneme lives on boltzmann, which sleeps; resolution would die with it, and a
refused); a mutable store turns a hard error into quiet wrong-target execution.
- **Availability.** The store may live on a host that sleeps; resolution would die with it, and a
local cache to cover that is just a slower, staler config file.
- **No net benefit.** The client needs a local mneme *address* regardless, so mneme only adds a
fragile network hop + cache to do what a flat file read already does.
- **No net benefit.** The client needs a local store *address* regardless, so the store only adds
a fragile network hop + cache to do what a flat file read already does.
mneme remains the fleet **memory** store — this decision is narrowly about *routing*, nothing else.
A shared memory store may still be useful for *memory* — this decision is narrowly about
*routing*, nothing else.
## Decision 2 — migration: **daemon dual-read, daemon-first**
The v2 daemon hard-requires the `0x00` magic; the deployed v1 client sends a digit first, so a
naive v2 deploy breaks every existing call on 30+ live hosts. Instead the daemon **sniffs the
first byte** — `0x00` → v2 parser, digit → v1-legacy parser — a once-per-connection branch,
~20 lines, zero perf cost. Ship the v2 daemon everywhere first; roll clients independently, no
flag day.
naive v2 deploy breaks every existing call on the many live hosts already running v1. Instead the
daemon **sniffs the first byte**`0x00` → v2 parser, digit → v1-legacy parser — a
once-per-connection branch, ~20 lines, zero perf cost. Ship the v2 daemon everywhere first; roll
clients independently, no flag day.
## Decision 3 — the chain link is `sicd` itself (no `sic-run` binary)
An earlier draft proposed a separate `sic-run` helper as the per-hop peeler. It is **not built**:
`sicd` is already a pure v2 frame-peeler, so the inner-hop command is just `sicd`
(`incus exec <c> -- sicd`), and one binary deploys instead of two (Markus' call: deployment
simplicity). **Verified empirically, not assumed**`sicd` invoking `sicd` was run against a
(`incus exec <c> -- sicd`), and one binary deploys instead of two (a deployment-simplicity
decision). **Verified empirically, not assumed**`sicd` invoking `sicd` was run against a
two-layer nested frame (`build sicd; frame("sicd", frame("cat", payload)) | sicd`): the payload
reached the inner hop, exit status propagated, stderr clean. sicd calling sicd peels correctly,
so nothing about a nested hop needs behaviour sicd lacks. The only prerequisite is that **sicd is
@@ -74,15 +74,15 @@ present in every container a chain traverses** (a packaging/rollout item, not ne
## Backward compat & the two original bugs
- A **bare `--`** must survive into the innermost argv untouched (the payload is opaque bytes;
no hop interprets it) — closes marfrit/sic#1.
no hop interprets it) — closes the double-dash bug (#1).
- **stdin must be forwarded** into/through the frame — closes the silent zero-byte-file bug.
- A **bare host** with no `/` stays a single v1-style hop (backward compatible).
## Escape hatch (documented, not built)
If redeploying clients on chain changes ever costs more than the trust of a live store,
distribute **signed manifests out-of-band** — deterministic like static config, updatable like
mneme, without the mutable-store poison vector. Future work; explicitly not today's problem.
distribute **signed manifests out-of-band** — deterministic like static config, updatable like a
live store, without the mutable-store poison vector. Future work; explicitly not today's problem.
## Consequences
@@ -90,4 +90,5 @@ mneme, without the mutable-store poison vector. Future work; explicitly not toda
- No network dependency and no mutable store in the routing path — routing is deterministic and
auditable.
- Next: implement `cmd/sic` (config read + recursive frame build + `--`/stdin fixes) and the
daemon dual-read; ensure sicd is deployed into target containers (no `sic-run` binary); both are Go, grindable via @godev.
daemon dual-read; ensure sicd is deployed into target containers (no `sic-run` binary); both
are Go.