diff --git a/go/internal/runtime/contract_host_test.go b/go/internal/runtime/contract_host_test.go new file mode 100644 index 00000000..763d4f2e --- /dev/null +++ b/go/internal/runtime/contract_host_test.go @@ -0,0 +1,96 @@ +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, + // 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 + // 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 9753caac..c83dd373 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" @@ -99,15 +100,84 @@ 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 + + // 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 +} + +// 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 +} + +// 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 @@ -120,13 +190,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 +219,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 +231,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 +246,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 +266,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 +297,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 +314,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 +337,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 +438,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) } @@ -363,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 new file mode 100644 index 00000000..6d5567ca --- /dev/null +++ b/go/internal/runtime/host_backend.go @@ -0,0 +1,538 @@ +// 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 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. +// - 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: 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 + +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. 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") +} + +// 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, and the live streaming process (nil until +// ExecStreaming spawns it). +type hostHandle struct { + id WorkloadID + name string + stateDir string + state hostState + 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 + // 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) + +// 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, + } + 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) + }() + // 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 + // 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() + + 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] + // 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 proc != nil { + select { + case <-proc.done: + default: + if killErr := killGroup(proc.pgid, syscall.SIGKILL); killErr != nil { + return killErr + } + <-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 00000000..ad2e14fc --- /dev/null +++ b/go/internal/runtime/host_backend_test.go @@ -0,0 +1,469 @@ +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" + "sync" + "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 "" + } +} + +// TestHostRemoveConcurrentWithExecStreaming drives Remove against a concurrent +// ExecStreaming on the same handle. handle.proc is written under the mutex, so +// 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 { + 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() + } +} + +// 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) + } +} diff --git a/go/internal/runtime/microvm.go b/go/internal/runtime/microvm.go index aa149f48..b155c562 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) } }