From 534dc23a6b5366f5dd9e72a103b4915f27f258bf Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 15:43:09 -0400 Subject: [PATCH 1/4] feat(runtime): add the host-process WorkloadRuntime backend (RIG-3512) Implements the frozen nine-method WorkloadRuntime as direct host child processes and registers it in SelectBackend as `host`. The default stays podman: host is opted into explicitly, never fallen back to. Create/Start/Exists are bookkeeping over a per-agent 0700 state dir; MountLabel and Resize are degenerate by construction and say so at the method, Resize returning a typed unsupported error rather than a silent success. Exec and ExecStreaming accept only the Runner's own euid as AsUser, erroring on any other uid rather than running it wrong. A leaked grandchild holding the output pipe past its parent's exit makes Go's WaitDelay fire on a command that already completed. That is not a spawn failure: the exit status is real, so it is reported with the captured output instead of being discarded. The child inherits the Runner's environment with ExecSpec.Env overriding per key. A host child has no image to supply a baseline, so without it even PATH is unset and an unqualified command cannot resolve. Joins the shared WorkloadRuntime contract suite as an untagged leg. The podman and microVM legs are gated on an engine or KVM being present, so the shared contract ran against nothing in most jobs; the host backend needs neither. The rows the engine legs pinned to a baked uid 1000 now take the uid from caps, because the host backend runs as the Runner's own euid and that is 1001 on a stock hosted runner. The defaults keep every podman and microVM assertion byte-identical. Co-authored-by: Matt Wilkinson --- go/internal/runtime/contract_host_test.go | 93 ++++ go/internal/runtime/contract_suite_test.go | 122 +++-- go/internal/runtime/host_backend.go | 511 +++++++++++++++++++++ go/internal/runtime/host_backend_test.go | 406 ++++++++++++++++ go/internal/runtime/microvm.go | 9 +- 5 files changed, 1106 insertions(+), 35 deletions(-) create mode 100644 go/internal/runtime/contract_host_test.go create mode 100644 go/internal/runtime/host_backend.go create mode 100644 go/internal/runtime/host_backend_test.go diff --git a/go/internal/runtime/contract_host_test.go b/go/internal/runtime/contract_host_test.go new file mode 100644 index 000000000..bd0ef442c --- /dev/null +++ b/go/internal/runtime/contract_host_test.go @@ -0,0 +1,93 @@ +package runtime + +// The host leg of the shared WorkloadRuntime contract suite (record §U5): runs +// runContractSuite against a real HostRuntime — direct host child processes +// under the Runner's own uid, with no container engine, VM, or KVM. It is +// UNTAGGED (no //go:build line) precisely because it needs none of those: the +// podman and microVM legs are build-tagged and gated on an engine/KVM being +// present, so in most CI jobs the shared contract runs against NOTHING. This leg +// gives the frozen 9-method contract actual continuous coverage on every runner. +// +// The host backend's uid rule is its one structural divergence from the engine +// legs, and it drives every caps choice here. A host child cannot switch user: +// it runs as the Runner's own effective uid, and HostRuntime.checkUser accepts +// ONLY that euid (host_backend.go:436-445). So the exec/stream rows run as +// os.Geteuid(), NOT the engine legs' baked "1000" — which is not even the host +// uid on a stock GitHub-hosted runner (host uid 1001, portability_test.go). The +// euidOnly cap points rowUIDEnforcement at that rule (an exec as the euid runs; +// an exec naming any other uid is refused), and resizeErr carries the host's +// distinct permanent ErrResizeUnsupportedOnHost (not the engine legs' C3-reserved +// ErrResizeNotImplemented). The microVM-specific divergence caps (output cap, +// numeric-uid-only, empty MountLabel, graceful power-off, portable kill error) +// are OFF: their rows self-skip. ignoresCommandAndCapAdd is ON because the host +// backend ALSO ignores both — Create/Start spawn no process (Command is not a +// keep-alive), and an unprivileged host child has an all-zero CapEff — so that +// row runs here and proves it truthfully. + +import ( + "os" + "strconv" + "testing" +) + +// TestContractSuite_Host drives the shared contract rows against a real +// HostRuntime through the WorkloadRuntime interface. It needs no engine and runs +// as an ordinary unprivileged user, so it is untagged and always on. The factory +// roots each runtime at a fresh t.TempDir(); makeSpec builds a name-only spec — +// no Image, no keep-alive Command (host Create spawns nothing) — the fields the +// handle model actually consumes. +func TestContractSuite_Host(t *testing.T) { + euid := strconv.Itoa(os.Geteuid()) + + caps := backendCaps{ + name: "host", + makeSpec: func(t *testing.T, name string) WorkloadSpec { + t.Helper() + // No Image (nothing to run) and no `sleep infinity` keep-alive: a + // host handle is bookkeeping until ExecStreaming spawns the agent, so + // Create/Start launch no process. Name is the Exists lookup key. + return WorkloadSpec{Name: name} + }, + // The exec/stream rows must run as the Runner's own euid — the only uid + // checkUser accepts — never the engine legs' baked "1000". + execUID: euid, + // rowUIDEnforcement's host branch: an exec as the euid runs, an exec + // naming euid+1 (a uid that is NOT the Runner's, whatever the euid is) is + // refused. + euidOnly: true, + rejectedUID: strconv.Itoa(os.Geteuid() + 1), + // Resize is a PERMANENT unsupported on host (no cgroup ownership), a + // deliberately distinct sentinel from the engine legs' C3-reserved one. + resizeErr: ErrResizeUnsupportedOnHost, + // The host backend ignores spec.Command (Create/Start spawn nothing) and + // spec.CapAdd (an unprivileged host child carries no added capability), so + // this divergence row runs here and proves both truthfully. + ignoresCommandAndCapAdd: true, + // microVM-specific divergences: all OFF, so those rows self-skip. Host + // capture is unbounded (no 8 MiB cap), it does not resolve user names + // (checkUser is euid-only, covered by euidOnly above), MountLabel is a + // no-error read, no guest powers off, and its deliberate-kill error is the + // byte-identical *exec.ExitError (ExitCode -1), never the portable type. + refusesRootExec: false, + numericUIDOnly: false, + emptyMountLabel: false, + capsOutput: false, + gracefulStopPowersOff: false, + portableKillError: false, + assertDuplicateName: func(t *testing.T, err error) { + t.Helper() + // Host Create refuses a duplicate name with a plain error keyed on + // spec.Name (host_backend.go Create); it has no typed collision error, + // so a non-nil error IS the contract. rowDuplicateName already + // asserted non-nil before calling this. + if err == nil { + t.Fatal("duplicate-name Create must be refused on host; got no error") + } + }, + } + + runContractSuite(t, func(t *testing.T) WorkloadRuntime { + t.Helper() + return NewHostRuntime(t.TempDir()) + }, caps) +} diff --git a/go/internal/runtime/contract_suite_test.go b/go/internal/runtime/contract_suite_test.go index 9753caac2..a13cd26ce 100644 --- a/go/internal/runtime/contract_suite_test.go +++ b/go/internal/runtime/contract_suite_test.go @@ -99,15 +99,54 @@ type backendCaps struct { // engine's name-in-use *CommandError on podman. Carried as a closure so the // shared body never names *DuplicateNameError (a unix-tagged symbol). assertDuplicateName func(t *testing.T, err error) + + // resizeErr is the sentinel rowResize expects from Resize. Nil means the + // shared ErrResizeNotImplemented (the engine legs' "reserved until C3" + // sentinel), so the podman/microVM legs need not set it. The host backend + // owns no cgroup and its Resize is a PERMANENT unsupported, so it sets + // ErrResizeUnsupportedOnHost — a deliberately distinct sentinel that must + // never be folded into the C3-reserved one. + resizeErr error + + // euidOnly selects the host uid-enforcement posture in rowUIDEnforcement: a + // host child cannot switch user, so the ONLY accepted uid is the Runner's + // own euid (execUID) and any other uid (rejectedUID) is refused. Off for the + // engine legs, which switch the workload to a directed uid. + euidOnly bool + + // execUID is the uid the directed exec rows run as, as a decimal string. + // Empty means "1000" (the baked agent uid the engine legs use). The host leg + // sets the Runner's own euid (os.Geteuid()), the only uid its execs may run + // as. + execUID string + + // rejectedUID is a uid the host backend must REFUSE (any uid other than its + // euid); used only when euidOnly is set. The engine legs leave it empty. + rejectedUID string +} + +// execUser is the uid the directed exec/stream rows run their commands as. The +// engine legs leave execUID empty and get "1000" (the baked agent uid their +// containers map the invoking host uid to). The host leg runs commands as its +// own euid — the only uid its checkUser accepts — which is NOT 1000 on a stock +// GitHub-hosted runner (host uid 1001, portability_test.go), so it MUST set +// execUID to os.Geteuid(); a hardcoded "1000" would fail every host exec row +// with UnsupportedUserError off the dev box. +func (c backendCaps) execUser() string { + if c.execUID != "" { + return c.execUID + } + return "1000" } -// runContractSuite is called only from the two build-tagged entrypoints -// (contract_podman_test.go, contract_microvm_test.go), so the untagged `unused` -// lint pass (the module's `golangci-lint ./...` lane runs without build tags) -// sees no caller for it or the row helpers it reaches. This blank reference is -// the untagged build's root into the suite graph, marking the whole reachable -// set used; under either build tag the real caller supersedes it. -var _ = runContractSuite +// resize is the sentinel rowResize expects: the caps override when set, else the +// shared C3-reserved ErrResizeNotImplemented the engine legs use. +func (c backendCaps) resize() error { + if c.resizeErr != nil { + return c.resizeErr + } + return ErrResizeNotImplemented +} // runContractSuite runs the shared rows against one backend, created via // newRuntime and described by caps. The stateless exec/stream rows share one @@ -120,13 +159,13 @@ func runContractSuite(t *testing.T, newRuntime func(t *testing.T) WorkloadRuntim rt := newRuntime(t) primary := startRunning(t, rt, caps, "contract-primary") - t.Run("exec_exit_codes", func(t *testing.T) { rowExecExitCodes(t, rt, primary) }) - t.Run("exec_stdin", func(t *testing.T) { rowExecStdin(t, rt, primary) }) + t.Run("exec_exit_codes", func(t *testing.T) { rowExecExitCodes(t, rt, caps, primary) }) + t.Run("exec_stdin", func(t *testing.T) { rowExecStdin(t, rt, caps, primary) }) t.Run("streaming_stdio", func(t *testing.T) { rowStreamingStdio(t, rt, caps, primary) }) t.Run("kill_wait_deliberate", func(t *testing.T) { rowKillWait(t, rt, caps, primary) }) - t.Run("ctx_cancel_reaps", func(t *testing.T) { rowCtxCancelReaps(t, rt, primary) }) + t.Run("ctx_cancel_reaps", func(t *testing.T) { rowCtxCancelReaps(t, rt, caps, primary) }) t.Run("uid_enforcement", func(t *testing.T) { rowUIDEnforcement(t, rt, caps, primary) }) - t.Run("resize_not_implemented", func(t *testing.T) { rowResize(t, rt, primary) }) + t.Run("resize_not_implemented", func(t *testing.T) { rowResize(t, rt, caps, primary) }) t.Run("mount_label", func(t *testing.T) { rowMountLabel(t, rt, caps, primary) }) if caps.numericUIDOnly { t.Run("non_numeric_user_refused", func(t *testing.T) { rowNonNumericUser(t, rt, primary) }) @@ -149,9 +188,9 @@ func runContractSuite(t *testing.T, newRuntime func(t *testing.T) WorkloadRuntim // echoed body; a non-zero exit is a SUCCESSFUL call returning the code, NEVER an // error. A regression that folded a non-zero exit into err would turn every // expected-failure probe (a denied firewall check) into a fatal. -func rowExecExitCodes(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { +func rowExecExitCodes(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() - out, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-c", "echo hello-body").AsUser("1000")) + out, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-c", "echo hello-body").AsUser(caps.execUser())) if err != nil { t.Fatalf("Exec(echo): %v", err) } @@ -161,7 +200,7 @@ func rowExecExitCodes(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { if !strings.Contains(out.Stdout, "hello-body") { t.Fatalf("Exec(echo) stdout = %q, want it to carry the echoed body", out.Stdout) } - out, err = rt.Exec(t.Context(), primary, NewExecSpec("sh", "-c", "exit 7").AsUser("1000")) + out, err = rt.Exec(t.Context(), primary, NewExecSpec("sh", "-c", "exit 7").AsUser(caps.execUser())) if err != nil { t.Fatalf("a non-zero exit must be a successful call, got err %v", err) } @@ -176,9 +215,9 @@ func rowExecExitCodes(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { // rowExecStdin — row 2 (record 567-569, agent.go:238-246): the script-over-stdin // shape end to end (the secret-safe channel). `sh -s` reads the script from // stdin, so the body never appears in the argv / process list. -func rowExecStdin(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { +func rowExecStdin(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() - out, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-s").WithStdin("echo from-stdin").AsUser("1000")) + out, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-s").WithStdin("echo from-stdin").AsUser(caps.execUser())) if err != nil { t.Fatalf("Exec(sh -s): %v", err) } @@ -196,7 +235,7 @@ func rowExecStdin(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { // terminate. func rowStreamingStdio(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() - stream, err := rt.ExecStreaming(t.Context(), primary, NewStreamingExecSpec("cat").AsUser("1000")) + stream, err := rt.ExecStreaming(t.Context(), primary, NewStreamingExecSpec("cat").AsUser(caps.execUser())) if err != nil { t.Fatalf("ExecStreaming(cat): %v", err) } @@ -227,7 +266,7 @@ func rowStreamingStdio(t *testing.T, rt WorkloadRuntime, caps backendCaps, prima // unregressed AND the microVM portable path works. func rowKillWait(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() - stream, err := rt.ExecStreaming(t.Context(), primary, NewStreamingExecSpec("sleep", "300").AsUser("1000")) + stream, err := rt.ExecStreaming(t.Context(), primary, NewStreamingExecSpec("sleep", "300").AsUser(caps.execUser())) if err != nil { t.Fatalf("ExecStreaming(sleep): %v", err) } @@ -244,10 +283,10 @@ func rowKillWait(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary Wor // no orphan survives a host-side cancel. Wait returning IS the reap signal (Wait // reaps). A bounded select fails loudly rather than hanging the suite if the // child is never reaped. -func rowCtxCancelReaps(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { +func rowCtxCancelReaps(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() cctx, cancel := context.WithCancel(t.Context()) - stream, err := rt.ExecStreaming(cctx, primary, NewStreamingExecSpec("sleep", "300").AsUser("1000")) + stream, err := rt.ExecStreaming(cctx, primary, NewStreamingExecSpec("sleep", "300").AsUser(caps.execUser())) if err != nil { cancel() t.Fatalf("ExecStreaming(sleep): %v", err) @@ -267,31 +306,50 @@ func rowCtxCancelReaps(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { // rowUIDEnforcement — row 6 (record 576, microvm-runner.md:358-360): a uid-0 // exec is refused on the microVM backend; the podman row asserts its equivalent // posture — a directed unprivileged exec runs as the requested uid, never -// silently escalated to root. +// silently escalated to root. The host leg (euidOnly) proves its distinct rule: +// a host child runs as the Runner's own euid and no other uid is accepted. func rowUIDEnforcement(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() + if caps.euidOnly { + // A host child cannot switch user, so an exec as the Runner's own euid + // runs and an exec naming any other uid is refused — the tier never runs + // a command under a uid the caller did not actually get. + out, err := rt.Exec(t.Context(), primary, NewExecSpec("id", "-u").AsUser(caps.execUser())) + if err != nil { + t.Fatalf("Exec(id -u) as the Runner's euid: %v", err) + } + if got := strings.TrimSpace(out.Stdout); got != caps.execUser() { + t.Fatalf("exec ran as uid %q, want the Runner's euid %q", got, caps.execUser()) + } + if _, err := rt.Exec(t.Context(), primary, NewExecSpec("id", "-u").AsUser(caps.rejectedUID)); err == nil { + t.Fatalf("an exec naming uid %q (not the Runner's euid) must be refused; got no error", caps.rejectedUID) + } + return + } if caps.refusesRootExec { if _, err := rt.Exec(t.Context(), primary, NewExecSpec("id", "-u").AsUser("0")); err == nil { t.Fatal("a uid-0 exec must be refused on this backend; got no error") } return } - out, err := rt.Exec(t.Context(), primary, NewExecSpec("id", "-u").AsUser("1000")) + out, err := rt.Exec(t.Context(), primary, NewExecSpec("id", "-u").AsUser(caps.execUser())) if err != nil { t.Fatalf("Exec(id -u): %v", err) } - if got := strings.TrimSpace(out.Stdout); got != "1000" { - t.Fatalf("directed unprivileged exec ran as uid %q, want 1000", got) + if got := strings.TrimSpace(out.Stdout); got != caps.execUser() { + t.Fatalf("directed unprivileged exec ran as uid %q, want %q", got, caps.execUser()) } } -// rowResize — row 11 (record 577-578): Resize returns ErrResizeNotImplemented on -// both backends until C3. The S1-frozen verb must refuse legibly, never fake a -// limit change that never happened. -func rowResize(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { +// rowResize — row 11 (record 577-578): Resize refuses legibly, never faking a +// limit change that never happened. The expected sentinel is caps.resize(): +// ErrResizeNotImplemented (the engine legs' C3-reserved sentinel) or, for the +// host leg, the distinct permanent ErrResizeUnsupportedOnHost. +func rowResize(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() - if err := rt.Resize(t.Context(), primary, ResourceLimits{CPUShares: 512}); !errors.Is(err, ErrResizeNotImplemented) { - t.Fatalf("Resize err = %v, want ErrResizeNotImplemented", err) + want := caps.resize() + if err := rt.Resize(t.Context(), primary, ResourceLimits{CPUShares: 512}); !errors.Is(err, want) { + t.Fatalf("Resize err = %v, want %v", err, want) } } @@ -349,11 +407,11 @@ func rowCommandCapAddIgnored(t *testing.T, rt WorkloadRuntime, caps backendCaps) if err := rt.Start(t.Context(), id); err != nil { t.Fatalf("Start must succeed though spec.Command is bogus (Command ignored): %v", err) } - out, err := rt.Exec(t.Context(), id, NewExecSpec("echo", "alive").AsUser("1000")) + out, err := rt.Exec(t.Context(), id, NewExecSpec("echo", "alive").AsUser(caps.execUser())) if err != nil || !out.Success() || !strings.Contains(out.Stdout, "alive") { t.Fatalf("exec must still work on the ignored-Command session: out=%+v err=%v", out, err) } - status, err := rt.Exec(t.Context(), id, NewExecSpec("cat", "/proc/self/status").AsUser("1000")) + status, err := rt.Exec(t.Context(), id, NewExecSpec("cat", "/proc/self/status").AsUser(caps.execUser())) if err != nil || !status.Success() { t.Fatalf("reading /proc/self/status: out=%+v err=%v", status, err) } diff --git a/go/internal/runtime/host_backend.go b/go/internal/runtime/host_backend.go new file mode 100644 index 000000000..d39ac2f5e --- /dev/null +++ b/go/internal/runtime/host_backend.go @@ -0,0 +1,511 @@ +// host_backend.go is the host-process WorkloadRuntime backend: the lowest +// substrate tier, running each agent as a direct child process of the Runner +// under the Runner's own uid, with no container, VM, or kernel boundary. It is +// a single-trust-domain tier — the operator is the only principal on the box — +// so several of the nine WorkloadRuntime methods are degenerate by +// construction, and each such method documents that at its definition. +// +// The model is a set of per-agent HANDLES keyed by workload id. A handle owns a +// private 0700 state dir (workspace root, home overlay dir, socket dir) and, +// once ExecStreaming launches the agent, the spawned child's process group. The +// handle map is mutex-guarded because the Runner calls these methods +// concurrently. +// +// WorkloadSpec field map on this backend: +// - Name honored: the handle's stable name (Exists lookup key). +// - Env honored: recorded on the handle for the launch path. +// - UID interpreted: the process runs as the Runner's own euid; the +// AsUser rule (below) enforces that, so this field is not a +// second uid source here. +// - Image ignored: there is no image to run. +// - CapAdd ignored: a host child carries the Runner's own capabilities; +// none are added or dropped here. +// - Mounts ignored: a host process has no bind mounts; the agent reads +// and writes the real host filesystem directly. +// - Command ignored: the long-lived agent is launched by ExecStreaming +// with its own command, not a container entrypoint. +// - Egress ignored: no per-workload firewall exists on the host; the +// unenforced-egress posture is armed elsewhere. + +package runtime + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + "os" + "os/exec" //nolint:depguard // host backend seam: *exec.Cmd/*exec.ExitError drive the direct host subprocess this backend exists to run + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// ErrResizeUnsupportedOnHost is returned by HostRuntime.Resize: the host +// backend owns no cgroup, so there is no live resource limit to change. It is a +// permanent unsupported (distinct from ErrResizeNotImplemented's "reserved +// until C3"), returned rather than a silent success so a caller that believes +// it resized is never lied to. +var ErrResizeUnsupportedOnHost = errors.New("runtime: WorkloadRuntime.Resize is unsupported on the host backend (no cgroup ownership)") + +// defaultHostStateRoot is where HostRuntime handles keep their per-agent state +// dirs when SelectBackend builds the backend with no explicit root. The host +// Provision leg supplies the real root later; this default keeps the backend +// usable on its own. +func defaultHostStateRoot() string { + return filepath.Join(os.TempDir(), "compass-host") +} + +// UnsupportedUserError is an ExecSpec/StreamingExecSpec AsUser naming a uid the +// host backend cannot honor. A host child runs as the Runner's own effective +// uid, so the only accepted value is that euid; any other uid means the caller +// believes a user switch happened, which the host tier cannot provide, so it is +// rejected rather than run wrong. +type UnsupportedUserError struct { + Requested string + Euid string +} + +func (e *UnsupportedUserError) Error() string { + return fmt.Sprintf("runtime: host backend cannot run as user %q: it runs as the Runner's own euid %q", e.Requested, e.Euid) +} + +// hostState is a handle's lifecycle: created by Create, advanced to started by +// Start. It is bookkeeping, not a running boundary — the agent process is a +// separate ExecStreaming child. +type hostState int + +const ( + hostCreated hostState = iota + hostStarted +) + +// hostProcess tracks the process group of a live streaming exec. pgid is the +// group leader's pid (the child is its own group leader via Setpgid); done +// closes when the reaper has waited the leader, after which waitErr is safe to +// read. +type hostProcess struct { + pgid int + done chan struct{} + waitErr error +} + +// hostHandle is one per-agent workload: its synthetic id and name, its private +// state dir, its lifecycle state, the WorkloadSpec env recorded for launch, and +// the live streaming process (nil until ExecStreaming spawns it). +type hostHandle struct { + id WorkloadID + name string + stateDir string + state hostState + env map[string]string + proc *hostProcess +} + +// HostRuntime runs each agent as a direct host child process under the Runner's +// own uid. It owns a mutex-guarded map of per-agent handles; euid is the +// Runner's effective uid captured at construction, the only uid AsUser accepts. +type HostRuntime struct { + stateRoot string + euid int + timeout time.Duration + mu sync.Mutex + handles map[WorkloadID]*hostHandle +} + +var _ WorkloadRuntime = (*HostRuntime)(nil) + +// NewHostRuntime builds a HostRuntime keeping per-agent state dirs under +// stateRoot, capturing the Runner's effective uid as the only uid its execs may +// run as. +func NewHostRuntime(stateRoot string) *HostRuntime { + return &HostRuntime{ + stateRoot: stateRoot, + euid: os.Geteuid(), + timeout: defaultCommandTimeout, + handles: make(map[WorkloadID]*hostHandle), + } +} + +// WithTimeout overrides the per-command wall-clock cap Exec applies. +func (h *HostRuntime) WithTimeout(timeout time.Duration) *HostRuntime { + h.timeout = timeout + return h +} + +// Create allocates a per-agent handle: it mints a synthetic WorkloadID and +// creates the handle's private 0700 state-dir tree (workspace root, home +// overlay dir, socket dir). No process is spawned. A duplicate name is refused +// under the same lock that inserts, so two concurrent Creates of one name +// cannot both pass. See the file header for the WorkloadSpec field map. +func (h *HostRuntime) Create(_ context.Context, spec WorkloadSpec) (WorkloadID, error) { + id, err := mintSessionID() + if err != nil { + return "", err + } + stateDir := filepath.Join(h.stateRoot, "host", string(id)) + for _, sub := range []string{"", "workspace", "home", "socket"} { + if mkErr := os.MkdirAll(filepath.Join(stateDir, sub), 0o700); mkErr != nil { + if rmErr := os.RemoveAll(stateDir); rmErr != nil { + return "", errors.Join(fmt.Errorf("runtime: host creating state dir: %w", mkErr), fmt.Errorf("runtime: host cleaning up refused state dir %s: %w", stateDir, rmErr)) + } + return "", fmt.Errorf("runtime: host creating state dir: %w", mkErr) + } + } + + h.mu.Lock() + defer h.mu.Unlock() + for _, existing := range h.handles { + if existing.name == spec.Name { + rmErr := os.RemoveAll(stateDir) + dupErr := fmt.Errorf("runtime: host workload named %q already exists", spec.Name) + if rmErr != nil { + return "", errors.Join(dupErr, fmt.Errorf("runtime: host cleaning up refused state dir %s: %w", stateDir, rmErr)) + } + return "", dupErr + } + } + h.handles[id] = &hostHandle{ + id: id, + name: spec.Name, + stateDir: stateDir, + state: hostCreated, + env: spec.Env, + } + return id, nil +} + +// Start transitions the handle created → started and validates its state dir. +// Bookkeeping only: there is no init process to launch (the agent is a later +// ExecStreaming child), so a "started" host handle is not a running boundary. +func (h *HostRuntime) Start(_ context.Context, id WorkloadID) error { + h.mu.Lock() + defer h.mu.Unlock() + handle, ok := h.handles[id] + if !ok { + return fmt.Errorf("runtime: host workload %q does not exist", id) + } + info, err := os.Stat(handle.stateDir) + if err != nil { + return fmt.Errorf("runtime: host validating state dir: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("runtime: host state path %q is not a directory", handle.stateDir) + } + handle.state = hostStarted + return nil +} + +// Exec runs the command as a direct host subprocess of the Runner, under the +// Runner's own uid, honoring ExecSpec's command/env/workdir/stdin under the +// per-command timeout. A non-zero exit is a successful call returning +// ExecOutput.ExitCode; only a spawn failure or a timeout is a Go error. The +// child inherits the Runner's environment with ExecSpec.Env overriding per key +// — there is no image here to supply a baseline. See checkUser for the AsUser +// rule. +func (h *HostRuntime) Exec(ctx context.Context, id WorkloadID, spec ExecSpec) (ExecOutput, error) { + if err := h.checkUser(spec.User); err != nil { + return ExecOutput{}, err + } + if _, err := h.startedHandle(id); err != nil { + return ExecOutput{}, err + } + if len(spec.Command) == 0 { + return ExecOutput{}, errors.New("runtime: host exec requires a command") + } + + cctx, cancel := context.WithTimeout(ctx, h.timeout) + defer cancel() + + //nolint:gosec // G204: the host backend seam spawns the Runner-assembled agent command; the argv is not attacker-controlled. + cmd := exec.CommandContext(cctx, spec.Command[0], spec.Command[1:]...) + cmd.WaitDelay = 10 * time.Second + cmd.Env = envSlice(spec.Env) + if spec.Workdir != nil { + cmd.Dir = *spec.Workdir + } + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if spec.Stdin != nil { + cmd.Stdin = strings.NewReader(*spec.Stdin) + } + + if runErr := cmd.Run(); runErr != nil { + switch { + case cctx.Err() == context.DeadlineExceeded && ctx.Err() == nil: + // This call's own timeout fired: the process was killed, so surface + // a timeout rather than a bogus exit code. + return ExecOutput{}, &TimeoutError{Summary: "host exec", Timeout: h.timeout} + case ctx.Err() != nil: + return ExecOutput{}, ctx.Err() + default: + if exitErr, ok := errors.AsType[*exec.ExitError](runErr); ok { + // Ran to completion but exited non-zero: a successful call. + return ExecOutput{Stdout: out.String(), Stderr: errBuf.String(), ExitCode: exitErr.ExitCode()}, nil + } + if errors.Is(runErr, exec.ErrWaitDelay) { + // The command itself exited; a leaked grandchild held the output + // pipe past that exit. The exit status is real, so report it with + // what was captured rather than losing it to a spawn error. + return ExecOutput{Stdout: out.String(), Stderr: errBuf.String(), ExitCode: cmd.ProcessState.ExitCode()}, nil + } + return ExecOutput{}, &SpawnError{Program: spec.Command[0], Err: runErr} + } + } + return ExecOutput{Stdout: out.String(), Stderr: errBuf.String(), ExitCode: 0}, nil +} + +// ExecStreaming spawns the agent as a host child in its own process group, +// stdio piped, bound to ctx so cancelling it terminates the process. This is +// the one clean mapping — where the host-tier agent comes to life. No +// wall-clock timeout: the process is meant to run indefinitely. Same AsUser +// rule as Exec. +func (h *HostRuntime) ExecStreaming(ctx context.Context, id WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) { + if err := h.checkUser(spec.User); err != nil { + return nil, err + } + handle, err := h.startedHandle(id) + if err != nil { + return nil, err + } + if len(spec.Command) == 0 { + return nil, errors.New("runtime: host streaming exec requires a command") + } + + execCtx, cancel := context.WithCancel(ctx) + //nolint:gosec // G204: the host backend seam spawns the Runner-assembled agent command; the argv is not attacker-controlled. + cmd := exec.CommandContext(execCtx, spec.Command[0], spec.Command[1:]...) + // Own process group so Stop/Remove can signal the whole tree, and ctx + // cancellation SIGKILLs that group rather than the leader alone. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return killGroup(cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = 10 * time.Second + cmd.Env = envSlice(spec.Env) + if spec.Workdir != nil { + cmd.Dir = *spec.Workdir + } + + // os.Pipe (not StdoutPipe) so the reaper goroutine can Wait without racing + // the caller's reads: exec closes only pipes it created, so these stay under + // the caller's ownership. + stdinR, stdinW, err := os.Pipe() + if err != nil { + cancel() + return nil, &SpawnError{Program: spec.Command[0], Err: err} + } + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + cancel() + return nil, errors.Join(&SpawnError{Program: spec.Command[0], Err: err}, closePipes(stdinR, stdinW)) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + cancel() + return nil, errors.Join(&SpawnError{Program: spec.Command[0], Err: err}, closePipes(stdinR, stdinW, stdoutR, stdoutW)) + } + cmd.Stdin = stdinR + cmd.Stdout = stdoutW + cmd.Stderr = stderrW + + if startErr := cmd.Start(); startErr != nil { + cancel() + return nil, errors.Join(&SpawnError{Program: spec.Command[0], Err: startErr}, closePipes(stdinR, stdinW, stdoutR, stdoutW, stderrR, stderrW)) + } + // Close the child-side ends in the parent so the caller sees EOF when the + // child exits and closes its own copies. + if closeErr := closePipes(stdinR, stdoutW, stderrW); closeErr != nil { + termErr := cmd.Cancel() + waitErr := cmd.Wait() + cancel() + return nil, errors.Join(&SpawnError{Program: spec.Command[0], Err: closeErr}, termErr, waitErr) + } + + proc := &hostProcess{pgid: cmd.Process.Pid, done: make(chan struct{})} + go func() { + proc.waitErr = cmd.Wait() + close(proc.done) + }() + + h.mu.Lock() + handle.proc = proc + h.mu.Unlock() + + kill := func() error { + cancel() + return killGroup(proc.pgid, syscall.SIGKILL) + } + wait := func() error { + <-proc.done + return proc.waitErr + } + return &StreamingExec{ + IO: StreamingIO{Stdin: stdinW, Stdout: stdoutR, Stderr: stderrR}, + Process: newChildHandleFuncs(kill, wait), + }, nil +} + +// Stop signals the handle's process group: SIGTERM, wait up to timeout, then +// SIGKILL. Scope is the group the backend spawned — a process the agent +// double-forked out of that group is NOT reliably stopped (no cgroup freezer in +// v1). Stopping a handle with no live process is a no-op. +func (h *HostRuntime) Stop(ctx context.Context, id WorkloadID, timeout time.Duration) error { + proc := h.liveProcess(id) + if proc == nil { + return nil + } + if termErr := killGroup(proc.pgid, syscall.SIGTERM); termErr != nil { + return termErr + } + select { + case <-proc.done: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-time.After(timeout): + if killErr := killGroup(proc.pgid, syscall.SIGKILL); killErr != nil { + return killErr + } + <-proc.done + return nil + } +} + +// Remove force-kills the process group if still live, then deletes the handle's +// state dir. It does NOT touch anything outside that dir: the agent's writes to +// the real host filesystem are permanent, the tier's declared posture, not a +// cleanup bug. Removing an unknown id is a no-op, for idempotent teardown. +func (h *HostRuntime) Remove(_ context.Context, id WorkloadID) error { + h.mu.Lock() + handle, ok := h.handles[id] + if ok { + delete(h.handles, id) + } + h.mu.Unlock() + if !ok { + return nil + } + if handle.proc != nil { + select { + case <-handle.proc.done: + default: + if killErr := killGroup(handle.proc.pgid, syscall.SIGKILL); killErr != nil { + return killErr + } + <-handle.proc.done + } + } + return os.RemoveAll(handle.stateDir) +} + +// Exists reports whether the backend holds a handle under this name. +// Degenerate: there is no container registry, so existence is handle-existence, +// not container-existence — a crashed agent whose state dir remains still +// Exists, mirroring a stopped-but-not-removed container. +func (h *HostRuntime) Exists(_ context.Context, name string) (bool, error) { + h.mu.Lock() + defer h.mu.Unlock() + for _, handle := range h.handles { + if handle.name == name { + return true, nil + } + } + return false, nil +} + +// MountLabel returns "", nil. Degenerate: there is no container and no +// per-container SELinux MCS category, so there is no label to relabel a config +// dir into. +func (h *HostRuntime) MountLabel(_ context.Context, _ WorkloadID) (string, error) { + return "", nil +} + +// Resize returns ErrResizeUnsupportedOnHost. Degenerate: the host backend owns +// no cgroup, so it never fakes a limit change that did not happen. +func (h *HostRuntime) Resize(_ context.Context, _ WorkloadID, _ ResourceLimits) error { + return ErrResizeUnsupportedOnHost +} + +// checkUser enforces the host AsUser rule: nil runs as the Runner's own euid, +// the euid as a numeric string is accepted, and any other uid is rejected — a +// host child cannot switch user, so accepting a different uid would run the +// caller's command wrong under a uid it did not ask for. +func (h *HostRuntime) checkUser(user *string) error { + if user == nil { + return nil + } + euid := strconv.Itoa(h.euid) + if *user == euid { + return nil + } + return &UnsupportedUserError{Requested: *user, Euid: euid} +} + +// startedHandle returns the handle for id, erroring if it is unknown or not yet +// started. +func (h *HostRuntime) startedHandle(id WorkloadID) (*hostHandle, error) { + h.mu.Lock() + defer h.mu.Unlock() + handle, ok := h.handles[id] + if !ok { + return nil, fmt.Errorf("runtime: host workload %q does not exist", id) + } + if handle.state != hostStarted { + return nil, fmt.Errorf("runtime: host workload %q is not started", id) + } + return handle, nil +} + +// liveProcess returns id's process if the handle exists and has one, else nil. +func (h *HostRuntime) liveProcess(id WorkloadID) *hostProcess { + h.mu.Lock() + defer h.mu.Unlock() + handle, ok := h.handles[id] + if !ok { + return nil + } + return handle.proc +} + +// killGroup signals the process group led by pgid. An already-gone group +// (ESRCH) is success — the process the signal targets has already exited. +func killGroup(pgid int, sig syscall.Signal) error { + if err := syscall.Kill(-pgid, sig); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("runtime: host signalling process group %d: %w", pgid, err) + } + return nil +} + +// closePipes closes every file, joining any close errors. +func closePipes(files ...*os.File) error { + var err error + for _, f := range files { + err = errors.Join(err, f.Close()) + } + return err +} + +// envSlice renders the child's environment: the Runner's own environment as the +// baseline, with env overriding it per key. A host child has no image to supply +// a baseline, so without inheritance even PATH is unset and an unqualified +// command cannot resolve. The agent already runs in the operator's own trust +// domain, so an isolated environment here would be a posture the tier does not +// actually have. +func envSlice(env map[string]string) []string { + merged := make(map[string]string, len(env)) + for _, kv := range os.Environ() { + if k, v, ok := strings.Cut(kv, "="); ok { + merged[k] = v + } + } + maps.Copy(merged, env) + out := make([]string, 0, len(merged)) + for k, v := range merged { + out = append(out, k+"="+v) + } + return out +} diff --git a/go/internal/runtime/host_backend_test.go b/go/internal/runtime/host_backend_test.go new file mode 100644 index 000000000..8fed929d7 --- /dev/null +++ b/go/internal/runtime/host_backend_test.go @@ -0,0 +1,406 @@ +package runtime + +// host_backend_test.go exercises HostRuntime against real short-lived host +// processes — no mocks of the OS. Every test uses t.TempDir() for state and +// gates on process state (a pid file, an exit) rather than a fixed sleep, so it +// runs on any Linux box with no container engine present and does not flake. + +import ( + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// newHostRuntime builds a HostRuntime rooted at a temp dir. +func newHostRuntime(t *testing.T) *HostRuntime { + t.Helper() + return NewHostRuntime(t.TempDir()) +} + +// createStarted creates and starts a handle named name, returning its id. +func createStarted(t *testing.T, h *HostRuntime, name string) WorkloadID { + t.Helper() + id, err := h.Create(t.Context(), WorkloadSpec{Name: name}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := h.Start(t.Context(), id); err != nil { + t.Fatalf("Start: %v", err) + } + return id +} + +// TestHostLifecycle drives the full Create → Start → Exec → Stop → Remove → +// Exists path, asserting each transition's observable result. +func TestHostLifecycle(t *testing.T) { + h := newHostRuntime(t) + id, err := h.Create(t.Context(), WorkloadSpec{Name: "agent-1"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + exists, err := h.Exists(t.Context(), "agent-1") + if err != nil || !exists { + t.Fatalf("Exists after Create = (%v, %v), want (true, nil)", exists, err) + } + + // Exec before Start must fail: the handle is not started. + if _, execErr := h.Exec(t.Context(), id, NewExecSpec("true")); execErr == nil { + t.Fatal("Exec before Start = nil error, want not-started error") + } + + if startErr := h.Start(t.Context(), id); startErr != nil { + t.Fatalf("Start: %v", startErr) + } + + out, err := h.Exec(t.Context(), id, NewExecSpec("sh", "-c", "echo hi")) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if out.ExitCode != 0 || strings.TrimSpace(out.Stdout) != "hi" { + t.Fatalf("Exec out = %+v, want exit 0 stdout %q", out, "hi") + } + + if stopErr := h.Stop(t.Context(), id, time.Second); stopErr != nil { + t.Fatalf("Stop (no live process) = %v, want nil", stopErr) + } + + if rmErr := h.Remove(t.Context(), id); rmErr != nil { + t.Fatalf("Remove: %v", rmErr) + } + exists, err = h.Exists(t.Context(), "agent-1") + if err != nil || exists { + t.Fatalf("Exists after Remove = (%v, %v), want (false, nil)", exists, err) + } +} + +// TestHostCreateRefusesDuplicateName: a second Create of a live name is refused +// rather than silently clobbering the first handle. +func TestHostCreateRefusesDuplicateName(t *testing.T) { + h := newHostRuntime(t) + if _, err := h.Create(t.Context(), WorkloadSpec{Name: "dup"}); err != nil { + t.Fatalf("first Create: %v", err) + } + if _, err := h.Create(t.Context(), WorkloadSpec{Name: "dup"}); err == nil { + t.Fatal("second Create of duplicate name = nil, want an error") + } +} + +// TestHostExecNonZeroExitIsNotError is the contract's sharpest edge: a command +// that exits non-zero is a SUCCESSFUL call returning the exit code, never a Go +// error. +func TestHostExecNonZeroExitIsNotError(t *testing.T) { + h := newHostRuntime(t) + id := createStarted(t, h, "agent-x") + out, err := h.Exec(t.Context(), id, NewExecSpec("sh", "-c", "exit 3")) + if err != nil { + t.Fatalf("Exec exit-3 err = %v, want nil (non-zero exit is not an error)", err) + } + if out.ExitCode != 3 { + t.Fatalf("ExitCode = %d, want 3", out.ExitCode) + } +} + +// TestHostExecLeakedChildKeepsExitStatus: a command that SUCCEEDS but leaves a +// background child holding the output pipe must still report success. Go's +// WaitDelay fires on the orphan's inherited pipe, not on the command. A +// non-zero exit is already an *exec.ExitError and takes an earlier branch, so +// exit 0 is the only case that reaches the WaitDelay path — and the case where +// a completed run's verdict and output would otherwise be thrown away. +func TestHostExecLeakedChildKeepsExitStatus(t *testing.T) { + sleepBin, err := exec.LookPath("sleep") + if err != nil { + t.Skipf("sleep not on PATH: %v", err) + } + h := newHostRuntime(t) + id := createStarted(t, h, "agent-leak") + // The shell exits at once; the backgrounded child keeps stdout open well + // past the 10s WaitDelay. + script := sleepBin + " 30 & echo parent-done" + out, err := h.Exec(t.Context(), id, NewExecSpec("sh", "-c", script)) + if err != nil { + t.Fatalf("Exec with leaked child err = %v, want nil", err) + } + if out.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0 (the command succeeded)", out.ExitCode) + } + if !strings.Contains(out.Stdout, "parent-done") { + t.Fatalf("Stdout = %q, want it to retain the completed command's output", out.Stdout) + } +} + +// TestHostExecHonorsEnvWorkdirStdin: the child inherits the Runner's +// environment, ExecSpec.Env overrides it per key, and the child runs in its +// workdir and reads its stdin. +func TestHostExecHonorsEnvWorkdirStdin(t *testing.T) { + h := newHostRuntime(t) + id := createStarted(t, h, "agent-e") + dir := t.TempDir() + + spec := ExecSpec{ + Command: []string{"sh", "-c", "printf '%s\\n' \"$FOO\"; pwd; cat"}, + Env: map[string]string{"FOO": "bar"}, + Workdir: &dir, + } + spec = spec.WithStdin("stdin-payload") + + out, err := h.Exec(t.Context(), id, spec) + if err != nil { + t.Fatalf("Exec: %v", err) + } + // pwd may resolve symlinks (e.g. /tmp → /private/tmp), so compare the + // resolved forms. + wantDir, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + lines := strings.SplitN(out.Stdout, "\n", 2) + if len(lines) != 2 { + t.Fatalf("stdout = %q, want two lines", out.Stdout) + } + if !strings.HasPrefix(lines[0], "bar") { + t.Fatalf("env not honored: stdout %q, want FOO=bar prefix", out.Stdout) + } + gotDir, payload, _ := strings.Cut(lines[1], "\n") + if gotDir != wantDir { + t.Fatalf("workdir = %q, want %q", gotDir, wantDir) + } + if payload != "stdin-payload" { + t.Fatalf("stdin = %q, want %q", payload, "stdin-payload") + } +} + +// TestHostExecInheritsRunnerEnv: an unqualified command resolves because the +// child inherited the Runner's PATH. With no inheritance the host tier has no +// image to supply one, so this exits 127 (command not found) instead. +func TestHostExecInheritsRunnerEnv(t *testing.T) { + if os.Getenv("PATH") == "" { + t.Skip("Runner has no PATH to inherit") + } + h := newHostRuntime(t) + id := createStarted(t, h, "agent-inherit") + out, err := h.Exec(t.Context(), id, NewExecSpec("sh", "-c", "sleep 0 && echo resolved")) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if out.ExitCode != 0 { + t.Fatalf("ExitCode = %d (stderr %q), want 0: an unqualified command must resolve via the inherited PATH", out.ExitCode, out.Stderr) + } + if !strings.Contains(out.Stdout, "resolved") { + t.Fatalf("Stdout = %q, want \"resolved\"", out.Stdout) + } +} + +// TestHostExecTimeout: a command that overruns the per-command cap is killed +// and surfaced as a TimeoutError, not a ctx error. A short WithTimeout makes +// the internal deadline fire deterministically against a command that would +// otherwise run far longer. +func TestHostExecTimeout(t *testing.T) { + h := newHostRuntime(t).WithTimeout(50 * time.Millisecond) + id := createStarted(t, h, "agent-t") + _, err := h.Exec(t.Context(), id, NewExecSpec("sleep", "30")) + if _, ok := errors.AsType[*TimeoutError](err); !ok { + t.Fatalf("Exec err = %v, want *TimeoutError", err) + } +} + +// TestHostExecStreamingStreamsAndStops spawns a long-lived process, reads its +// streamed stdout, then Stop terminates it. Gates on the streamed marker line, +// never a fixed sleep. +func TestHostExecStreamingStreamsAndStops(t *testing.T) { + h := newHostRuntime(t) + id := createStarted(t, h, "agent-s") + + // Emit a marker, then loop forever. The marker proves the stream is live + // before we stop it. + spec := NewStreamingExecSpec("sh", "-c", "echo started; while true; do sleep 1; done") + se, err := h.ExecStreaming(t.Context(), id, spec) + if err != nil { + t.Fatalf("ExecStreaming: %v", err) + } + + if line := readLine(t, se.IO.Stdout); strings.TrimSpace(line) != "started" { + t.Fatalf("first stdout line = %q, want %q", line, "started") + } + + if stopErr := h.Stop(t.Context(), id, 5*time.Second); stopErr != nil { + t.Fatalf("Stop: %v", stopErr) + } + // After Stop the process is gone: Wait returns (a signalled exit is a + // non-nil error, which is expected). + _ = se.Process.Wait() +} + +// TestHostStopEscalatesToSIGKILL: a process that traps and ignores SIGTERM is +// still stopped, because Stop escalates to SIGKILL after the timeout. +func TestHostStopEscalatesToSIGKILL(t *testing.T) { + h := newHostRuntime(t) + id := createStarted(t, h, "agent-k") + + // Trap SIGTERM (ignore it), announce readiness, then sleep forever. Only + // SIGKILL can stop it. + script := "trap '' TERM; echo ready; while true; do sleep 1; done" + se, err := h.ExecStreaming(t.Context(), id, NewStreamingExecSpec("sh", "-c", script)) + if err != nil { + t.Fatalf("ExecStreaming: %v", err) + } + if line := readLine(t, se.IO.Stdout); strings.TrimSpace(line) != "ready" { + t.Fatalf("first stdout line = %q, want %q", line, "ready") + } + + start := time.Now() + if stopErr := h.Stop(t.Context(), id, 500*time.Millisecond); stopErr != nil { + t.Fatalf("Stop: %v", stopErr) + } + // Stop returned only after the SIGKILL escalation, so it waited at least the + // grace window; the process is now reaped. + if elapsed := time.Since(start); elapsed < 400*time.Millisecond { + t.Fatalf("Stop returned in %s, want >= the ~500ms grace before SIGKILL", elapsed) + } + waitErr := se.Process.Wait() + if waitErr == nil { + t.Fatal("Wait after SIGKILL = nil, want a signalled-exit error") + } +} + +// TestHostAsUser: nil and the Runner's own euid are accepted; any other uid is +// rejected with UnsupportedUserError. +func TestHostAsUser(t *testing.T) { + h := newHostRuntime(t) + id := createStarted(t, h, "agent-u") + euid := strconv.Itoa(os.Geteuid()) + + if _, err := h.Exec(t.Context(), id, NewExecSpec("true").AsUser(euid)); err != nil { + t.Fatalf("Exec AsUser(euid) = %v, want nil", err) + } + + other := strconv.Itoa(os.Geteuid() + 1) + _, err := h.Exec(t.Context(), id, NewExecSpec("true").AsUser(other)) + var unsupported *UnsupportedUserError + if !errors.As(err, &unsupported) { + t.Fatalf("Exec AsUser(other) err = %v, want *UnsupportedUserError", err) + } + + // Streaming honors the same rule. + _, err = h.ExecStreaming(t.Context(), id, NewStreamingExecSpec("sleep", "1").AsUser(other)) + if !errors.As(err, &unsupported) { + t.Fatalf("ExecStreaming AsUser(other) err = %v, want *UnsupportedUserError", err) + } +} + +// TestHostMountLabelAndResize: MountLabel is empty, Resize is the typed +// unsupported error. +func TestHostMountLabelAndResize(t *testing.T) { + h := newHostRuntime(t) + id := createStarted(t, h, "agent-d") + + label, err := h.MountLabel(t.Context(), id) + if err != nil || label != "" { + t.Fatalf("MountLabel = (%q, %v), want (\"\", nil)", label, err) + } + + if resizeErr := h.Resize(t.Context(), id, ResourceLimits{CPUShares: 512}); !errors.Is(resizeErr, ErrResizeUnsupportedOnHost) { + t.Fatalf("Resize err = %v, want ErrResizeUnsupportedOnHost", resizeErr) + } +} + +// TestHostRemoveDeletesStateDirNotSiblings: Remove deletes the handle's state +// dir but never touches a sibling file outside it — the tier's permanent-writes +// posture. +func TestHostRemoveDeletesStateDirNotSiblings(t *testing.T) { + h := newHostRuntime(t) + id, err := h.Create(t.Context(), WorkloadSpec{Name: "agent-r"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // A sibling file under the shared state root, outside any handle's dir. + sibling := filepath.Join(h.stateRoot, "sibling.txt") + if writeErr := os.WriteFile(sibling, []byte("keep me"), 0o600); writeErr != nil { + t.Fatalf("writing sibling: %v", writeErr) + } + + stateDir := h.handles[id].stateDir + if _, statErr := os.Stat(stateDir); statErr != nil { + t.Fatalf("state dir not created: %v", statErr) + } + + if rmErr := h.Remove(t.Context(), id); rmErr != nil { + t.Fatalf("Remove: %v", rmErr) + } + if _, statErr := os.Stat(stateDir); !os.IsNotExist(statErr) { + t.Fatalf("state dir still present after Remove: stat err = %v", statErr) + } + if _, statErr := os.Stat(sibling); statErr != nil { + t.Fatalf("sibling file removed or unreadable after Remove: %v", statErr) + } +} + +// TestHostRemoveKillsLiveProcess: Remove force-kills a still-running streaming +// child before deleting the state dir. +func TestHostRemoveKillsLiveProcess(t *testing.T) { + h := newHostRuntime(t) + id := createStarted(t, h, "agent-rk") + se, err := h.ExecStreaming(t.Context(), id, NewStreamingExecSpec("sh", "-c", "echo up; while true; do sleep 1; done")) + if err != nil { + t.Fatalf("ExecStreaming: %v", err) + } + if line := readLine(t, se.IO.Stdout); strings.TrimSpace(line) != "up" { + t.Fatalf("first stdout line = %q, want %q", line, "up") + } + + if rmErr := h.Remove(t.Context(), id); rmErr != nil { + t.Fatalf("Remove: %v", rmErr) + } + // The child was SIGKILLed by Remove; Wait now returns the signalled exit. + waitErr := se.Process.Wait() + if _, ok := errors.AsType[*exec.ExitError](waitErr); !ok { + t.Fatalf("Wait after Remove = %v, want an *exec.ExitError from the killed child", waitErr) + } +} + +// readLine reads a single newline-terminated line from r, failing the test if +// none arrives within a generous deadline. Gating on the line — not a sleep — +// is what keeps the streaming tests non-flaky. +func readLine(t *testing.T, r io.Reader) string { + t.Helper() + type result struct { + line string + err error + } + ch := make(chan result, 1) + go func() { + buf := make([]byte, 0, 64) + one := make([]byte, 1) + for { + n, err := r.Read(one) + if n > 0 { + if one[0] == '\n' { + ch <- result{line: string(buf)} + return + } + buf = append(buf, one[0]) + } + if err != nil { + ch <- result{line: string(buf), err: err} + return + } + } + }() + select { + case res := <-ch: + if res.err != nil && res.line == "" { + t.Fatalf("readLine: %v", res.err) + } + return res.line + case <-time.After(10 * time.Second): + t.Fatal("readLine: no line within 10s") + return "" + } +} diff --git a/go/internal/runtime/microvm.go b/go/internal/runtime/microvm.go index aa149f48a..b155c562a 100644 --- a/go/internal/runtime/microvm.go +++ b/go/internal/runtime/microvm.go @@ -128,8 +128,9 @@ func NewMicroVMRuntime(cfg MicroVMConfig) *MicroVMRuntime { // SelectBackend chooses the workload runtime backend from cfg. An empty or // "podman" backend returns the podman CLI runtime; "microvm" returns the // microVM runtime; "apple-container" returns the Apple `container` CLI runtime -// (the macOS arm); any other value is an error naming the unknown backend and -// the accepted values. +// (the macOS arm); "host" returns the direct-host-process runtime (the +// single-trust-domain tier); any other value is an error naming the unknown +// backend and the accepted values. // // During the transitional period both backends ship and the default is podman: // the proven container path stays the floor while the microVM backend is @@ -146,7 +147,9 @@ func SelectBackend(cfg BackendConfig) (WorkloadRuntime, error) { return NewMicroVMRuntime(cfg.MicroVM), nil case "apple-container": return NewAppleContainerCLI(cfg.AppleContainer), nil + case "host": + return NewHostRuntime(defaultHostStateRoot()), nil default: - return nil, fmt.Errorf("runtime: unknown backend %q: accepted values are \"podman\" (default), \"microvm\" and \"apple-container\"", cfg.Backend) + return nil, fmt.Errorf("runtime: unknown backend %q: accepted values are \"podman\" (default), \"microvm\", \"apple-container\" and \"host\"", cfg.Backend) } } From ec9817aeb000fb9a631027415096ca589811ceaa Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 18:15:29 -0400 Subject: [PATCH 2/4] fix(runtime): close the Remove/ExecStreaming race and fix the host CapEff assertion (RIG-3512) Remove read handle.proc after dropping the mutex while ExecStreaming writes it under the mutex. Besides the data race, Remove could see a nil proc for a child being spawned, skip the kill, and drop the handle -- leaving a live process under the operator's uid that nothing owns. The capture now happens in the same critical section as the map delete. The shared CapAdd row asserted an all-zero CapEff. A host child inherits the Runner's capabilities, so that asserted the test runner is unprivileged rather than that CapAdd was ignored, and would red on a CI runner with any capability. The host leg now compares the child's set to the Runner's own; the engine legs keep the all-zero assertion. Also drops the write-only handle env field rather than document it as honored, and stops the egress and state-root comments implying protections the tier does not have. Co-authored-by: Matt Wilkinson --- go/internal/runtime/contract_host_test.go | 3 ++ go/internal/runtime/contract_suite_test.go | 36 ++++++++++++++++++++-- go/internal/runtime/host_backend.go | 35 ++++++++++++--------- go/internal/runtime/host_backend_test.go | 32 +++++++++++++++++++ 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/go/internal/runtime/contract_host_test.go b/go/internal/runtime/contract_host_test.go index bd0ef442c..763d4f2e1 100644 --- a/go/internal/runtime/contract_host_test.go +++ b/go/internal/runtime/contract_host_test.go @@ -63,6 +63,9 @@ func TestContractSuite_Host(t *testing.T) { // spec.CapAdd (an unprivileged host child carries no added capability), so // this divergence row runs here and proves both truthfully. ignoresCommandAndCapAdd: true, + // A host child inherits the Runner's capabilities; "CapAdd added nothing" + // means the child's set equals the Runner's, not that it is empty. + inheritsRunnerCaps: true, // microVM-specific divergences: all OFF, so those rows self-skip. Host // capture is unbounded (no 8 MiB cap), it does not resolve user names // (checkUser is euid-only, covered by euidOnly above), MountLabel is a diff --git a/go/internal/runtime/contract_suite_test.go b/go/internal/runtime/contract_suite_test.go index a13cd26ce..c83dd3739 100644 --- a/go/internal/runtime/contract_suite_test.go +++ b/go/internal/runtime/contract_suite_test.go @@ -25,6 +25,7 @@ import ( "context" "errors" "io" + "os" "os/exec" "strings" "testing" @@ -120,6 +121,12 @@ type backendCaps struct { // as. execUID string + // inheritsRunnerCaps: the workload inherits the Runner's own capability set + // rather than starting from an empty container set (host). It changes what + // "CapAdd granted nothing" means in rowCommandCapAddIgnored; the engine legs + // leave it false and keep the all-zero assertion. + inheritsRunnerCaps bool + // rejectedUID is a uid the host backend must REFUSE (any uid other than its // euid); used only when euidOnly is set. The engine legs leave it empty. rejectedUID string @@ -148,6 +155,30 @@ func (c backendCaps) resize() error { return ErrResizeNotImplemented } +// wantCapEff is the CapEff the workload must show for spec.CapAdd to have +// granted nothing. The engine legs start from an empty container capability +// set, so they expect all-zero. A host child inherits the Runner's own +// capabilities, so "added nothing" there means "the same set the Runner has" — +// expecting zero would instead assert the Runner is unprivileged, which is a +// property of how CI launches the test, not of this backend. +func (c backendCaps) wantCapEff(t *testing.T) string { + t.Helper() + if !c.inheritsRunnerCaps { + return "0000000000000000" + } + status, err := os.ReadFile("/proc/self/status") + if err != nil { + t.Skipf("reading own /proc/self/status: %v", err) + } + for line := range strings.SplitSeq(string(status), "\n") { + if rest, ok := strings.CutPrefix(line, "CapEff:"); ok { + return strings.TrimSpace(rest) + } + } + t.Fatal("no CapEff line in own /proc/self/status") + return "" +} + // runContractSuite runs the shared rows against one backend, created via // newRuntime and described by caps. The stateless exec/stream rows share one // running container (booted once, amortized on the KVM-gated microVM leg); the @@ -421,8 +452,9 @@ func rowCommandCapAddIgnored(t *testing.T, rt WorkloadRuntime, caps backendCaps) capEff = strings.TrimSpace(rest) } } - if capEff != "0000000000000000" { - t.Fatalf("workload CapEff = %q, want the empty set (spec.CapAdd must grant the workload nothing)", capEff) + want := caps.wantCapEff(t) + if capEff != want { + t.Fatalf("workload CapEff = %q, want %q (spec.CapAdd must grant the workload nothing)", capEff, want) } } diff --git a/go/internal/runtime/host_backend.go b/go/internal/runtime/host_backend.go index d39ac2f5e..33e8d0b43 100644 --- a/go/internal/runtime/host_backend.go +++ b/go/internal/runtime/host_backend.go @@ -13,7 +13,9 @@ // // WorkloadSpec field map on this backend: // - Name honored: the handle's stable name (Exists lookup key). -// - Env honored: recorded on the handle for the launch path. +// - Env ignored here: Exec/ExecStreaming take their environment +// from the ExecSpec, and the launch leg that would apply this +// field does not exist yet. // - UID interpreted: the process runs as the Runner's own euid; the // AsUser rule (below) enforces that, so this field is not a // second uid source here. @@ -24,8 +26,9 @@ // and writes the real host filesystem directly. // - Command ignored: the long-lived agent is launched by ExecStreaming // with its own command, not a container entrypoint. -// - Egress ignored: no per-workload firewall exists on the host; the -// unenforced-egress posture is armed elsewhere. +// - Egress ignored: egress is UNENFORCED on this tier. There is no +// per-workload firewall and nothing here arms one, so this +// backend never claims otherwise. package runtime @@ -53,9 +56,10 @@ import ( var ErrResizeUnsupportedOnHost = errors.New("runtime: WorkloadRuntime.Resize is unsupported on the host backend (no cgroup ownership)") // defaultHostStateRoot is where HostRuntime handles keep their per-agent state -// dirs when SelectBackend builds the backend with no explicit root. The host -// Provision leg supplies the real root later; this default keeps the backend -// usable on its own. +// dirs when SelectBackend builds the backend with no explicit root. This is a +// shared tmpdir: MkdirAll will not tighten an existing dir, so on a multi-user +// box a local user could pre-create it with looser permissions. Deployments +// pass their own private root instead. func defaultHostStateRoot() string { return filepath.Join(os.TempDir(), "compass-host") } @@ -95,14 +99,13 @@ type hostProcess struct { } // hostHandle is one per-agent workload: its synthetic id and name, its private -// state dir, its lifecycle state, the WorkloadSpec env recorded for launch, and -// the live streaming process (nil until ExecStreaming spawns it). +// state dir, its lifecycle state, and the live streaming process (nil until +// ExecStreaming spawns it). type hostHandle struct { id WorkloadID name string stateDir string state hostState - env map[string]string proc *hostProcess } @@ -174,7 +177,6 @@ func (h *HostRuntime) Create(_ context.Context, spec WorkloadSpec) (WorkloadID, name: spec.Name, stateDir: stateDir, state: hostCreated, - env: spec.Env, } return id, nil } @@ -382,21 +384,26 @@ func (h *HostRuntime) Stop(ctx context.Context, id WorkloadID, timeout time.Dura func (h *HostRuntime) Remove(_ context.Context, id WorkloadID) error { h.mu.Lock() handle, ok := h.handles[id] + // Capture the process in the same critical section that drops the handle: + // ExecStreaming writes handle.proc under this lock, so reading it after + // unlocking both races and can miss a child that is about to be spawned. + var proc *hostProcess if ok { + proc = handle.proc delete(h.handles, id) } h.mu.Unlock() if !ok { return nil } - if handle.proc != nil { + if proc != nil { select { - case <-handle.proc.done: + case <-proc.done: default: - if killErr := killGroup(handle.proc.pgid, syscall.SIGKILL); killErr != nil { + if killErr := killGroup(proc.pgid, syscall.SIGKILL); killErr != nil { return killErr } - <-handle.proc.done + <-proc.done } } return os.RemoveAll(handle.stateDir) diff --git a/go/internal/runtime/host_backend_test.go b/go/internal/runtime/host_backend_test.go index 8fed929d7..1689d492c 100644 --- a/go/internal/runtime/host_backend_test.go +++ b/go/internal/runtime/host_backend_test.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "testing" "time" ) @@ -404,3 +405,34 @@ func readLine(t *testing.T, r io.Reader) string { return "" } } + +// TestHostRemoveConcurrentWithExecStreaming drives Remove against a concurrent +// ExecStreaming on the same handle. handle.proc is written under the mutex, so +// reading it unlocked is both a data race and a missed kill: Remove can see nil +// and drop the handle while the child is still being spawned, leaving a live +// process nothing owns. Run under -race. +func TestHostRemoveConcurrentWithExecStreaming(t *testing.T) { + sleepBin, err := exec.LookPath("sleep") + if err != nil { + t.Skipf("sleep not on PATH: %v", err) + } + for range 40 { + h := newHostRuntime(t) + id := createStarted(t, h, "agent-race") + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + stream, execErr := h.ExecStreaming(t.Context(), id, NewStreamingExecSpec(sleepBin, "300")) + if execErr == nil { + _ = stream.Process.Kill() + _ = stream.Process.Wait() + } + }() + go func() { + defer wg.Done() + _ = h.Remove(t.Context(), id) + }() + wg.Wait() + } +} From 7d4b70694db5a1cc562419c815b310ae2af16a70 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 19:19:20 -0400 Subject: [PATCH 3/4] fix(runtime): refuse a host streaming exec whose workload was removed mid-spawn (RIG-3512) ExecStreaming records the process on the handle only after cmd.Start, so a Remove landing in that gap found no process to kill, deleted the handle, and wiped the state dir under a child that was already running. Nothing could reach that child afterwards, because Remove addresses a workload by id and the id was gone. ExecStreaming now re-checks the handle is still present before recording the process, and reaps its own child and errors when it is not. The concurrency test's comment claimed it covered this; it does not, so it now says it guards the data race only. A test for the window itself is not included: the interleaving is not reachable from the public API, because the handle lookup rejects a removed id before the spawn begins. Co-authored-by: Matt Wilkinson --- go/internal/runtime/host_backend.go | 12 ++++++++++++ go/internal/runtime/host_backend_test.go | 5 ++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/go/internal/runtime/host_backend.go b/go/internal/runtime/host_backend.go index 33e8d0b43..4096365ac 100644 --- a/go/internal/runtime/host_backend.go +++ b/go/internal/runtime/host_backend.go @@ -333,7 +333,19 @@ func (h *HostRuntime) ExecStreaming(ctx context.Context, id WorkloadID, spec Str close(proc.done) }() + // The child is already running, so a Remove that landed during the spawn + // would have found no process to kill. Detect that and reap our own child + // rather than hand back a live workload the backend can no longer reach. h.mu.Lock() + if _, live := h.handles[id]; !live { + h.mu.Unlock() + cancel() + <-proc.done + return nil, errors.Join( + fmt.Errorf("runtime: host workload %q was removed during spawn", id), + closePipes(stdinW, stdoutR, stderrR), + ) + } handle.proc = proc h.mu.Unlock() diff --git a/go/internal/runtime/host_backend_test.go b/go/internal/runtime/host_backend_test.go index 1689d492c..0f3edbb4c 100644 --- a/go/internal/runtime/host_backend_test.go +++ b/go/internal/runtime/host_backend_test.go @@ -408,9 +408,8 @@ func readLine(t *testing.T, r io.Reader) string { // TestHostRemoveConcurrentWithExecStreaming drives Remove against a concurrent // ExecStreaming on the same handle. handle.proc is written under the mutex, so -// reading it unlocked is both a data race and a missed kill: Remove can see nil -// and drop the handle while the child is still being spawned, leaving a live -// process nothing owns. Run under -race. +// reading it unlocked is a data race. This guards that race only — it is +// meaningful under -race and asserts nothing about which of the two wins. func TestHostRemoveConcurrentWithExecStreaming(t *testing.T) { sleepBin, err := exec.LookPath("sleep") if err != nil { From fdb1562f99198c2d155b84de255513641f7ca704 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 20:07:38 -0400 Subject: [PATCH 4/4] test(runtime): cover the host removal-during-spawn window (RIG-3512) The previous commit shipped this fix untested on the grounds that the window was unreachable from the public API. That was wrong: startedHandle releases the lock before returning, so the whole span from there through the spawn is lock-free and a concurrent Remove can land anywhere inside it. An after-spawn hook, nil in production, lets the test occupy that gap directly instead of racing for it. Reverting the re-check now fails the test in milliseconds; the race-based attempt could not fail at all, because the handle lookup rejects a removed id before the spawn starts and won almost every interleaving. Co-authored-by: Matt Wilkinson --- go/internal/runtime/host_backend.go | 8 ++++++ go/internal/runtime/host_backend_test.go | 32 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/go/internal/runtime/host_backend.go b/go/internal/runtime/host_backend.go index 4096365ac..6d5567cac 100644 --- a/go/internal/runtime/host_backend.go +++ b/go/internal/runtime/host_backend.go @@ -118,6 +118,9 @@ type HostRuntime struct { timeout time.Duration mu sync.Mutex handles map[WorkloadID]*hostHandle + // afterSpawn, when set by a test, runs between the spawn and the handle + // record in ExecStreaming. Nil in production. + afterSpawn func() } var _ WorkloadRuntime = (*HostRuntime)(nil) @@ -332,6 +335,11 @@ func (h *HostRuntime) ExecStreaming(ctx context.Context, id WorkloadID, spec Str proc.waitErr = cmd.Wait() close(proc.done) }() + // Test seam: lets a test occupy the gap between the spawn and the record + // below, which is otherwise a lock-free window no caller can time. + if h.afterSpawn != nil { + h.afterSpawn() + } // The child is already running, so a Remove that landed during the spawn // would have found no process to kill. Detect that and reap our own child diff --git a/go/internal/runtime/host_backend_test.go b/go/internal/runtime/host_backend_test.go index 0f3edbb4c..ad2e14fc1 100644 --- a/go/internal/runtime/host_backend_test.go +++ b/go/internal/runtime/host_backend_test.go @@ -435,3 +435,35 @@ func TestHostRemoveConcurrentWithExecStreaming(t *testing.T) { wg.Wait() } } + +// TestHostExecStreamingRemovedDuringSpawn pins the removal-during-spawn +// window. startedHandle releases the lock before the spawn, so a concurrent +// Remove can delete the handle and wipe the state dir while the child is +// already running and not yet recorded — after which nothing can reach it by +// id. The seam forces that interleaving instead of racing for it. +func TestHostExecStreamingRemovedDuringSpawn(t *testing.T) { + sleepBin, err := exec.LookPath("sleep") + if err != nil { + t.Skipf("sleep not on PATH: %v", err) + } + h := newHostRuntime(t) + id := createStarted(t, h, "agent-removed") + h.afterSpawn = func() { + if removeErr := h.Remove(t.Context(), id); removeErr != nil { + t.Errorf("Remove during spawn: %v", removeErr) + } + } + + stream, err := h.ExecStreaming(t.Context(), id, NewStreamingExecSpec(sleepBin, "300")) + if err == nil { + _ = stream.Process.Kill() + _ = stream.Process.Wait() + t.Fatal("ExecStreaming returned a live stream for a workload removed mid-spawn; the child would outlive every way of reaching it") + } + if stream != nil { + t.Fatalf("ExecStreaming returned a stream alongside err = %v", err) + } + if !strings.Contains(err.Error(), "removed during spawn") { + t.Fatalf("err = %v, want the removed-during-spawn refusal", err) + } +}