From 1838b7a1802c94554c42b06e78ac3b93c7fa216e Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 20:23:44 -0400 Subject: [PATCH 1/2] feat(runner): derive the agent uid from the Runner's euid on the host backend (RIG-3512) The baked agent uid names the uid the agent image bakes /nix and $HOME as. The host backend runs no image, so on a Runner whose euid is not 1000 -- the normal case, and the premise of the tier -- every provision exec passed AsUser(1000) and the backend refused it. The uid now comes from the Runner's own effective uid whenever the resolved engine is the host backend, read from the engine itself rather than by re-deriving the backend name, so the flag-then-env fallback stays in the one place that applies it. Every container tier keeps the baked constant, which their userns remap maps the invoking uid onto. Geteuid returns -1 where the syscall is missing, so a negative value is refused instead of wrapping to a huge uid. Root still falls to the existing non-root check, which refuses it at startup rather than at the first provision. Co-authored-by: Matt Wilkinson --- go/cmd/compass-runner/main.go | 49 +++++++++-- go/cmd/compass-runner/uid_test.go | 133 ++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 go/cmd/compass-runner/uid_test.go diff --git a/go/cmd/compass-runner/main.go b/go/cmd/compass-runner/main.go index 969093fe3..e9ec2fcce 100644 --- a/go/cmd/compass-runner/main.go +++ b/go/cmd/compass-runner/main.go @@ -145,15 +145,7 @@ func run() error { return err } } - specs, err := runner.NewConfigSpecBuilder(runner.SpecDefaults{ - Image: img, - Egress: egress, - CheckoutDir: *checkoutDir, - HomeDir: *homeDir, - UID: agentuid.AgentUID, - NamePrefix: runner.AgentContainerNamePrefix, - Mounts: mounts, - }) + specs, err := newSpecBuilder(engine, img, egress, *checkoutDir, *homeDir, mounts) if err != nil { return err } @@ -182,6 +174,45 @@ func run() error { }, specs, log) } +// specDefaultsUID resolves the uid every agent workspace runs as, keyed off the +// already-resolved engine so the flag/env backend fallback is not re-derived. +// The host backend runs agents as direct children under the Runner's own uid, +// so it derives the euid via geteuid; every container tier keeps the baked fleet +// constant, which its userns remap maps the invoking host uid onto. geteuid is a +// seam so the narrowing edges are reachable in a test. +func specDefaultsUID(engine runtime.WorkloadRuntime, geteuid func() int) (uint32, error) { + if _, ok := engine.(*runtime.HostRuntime); !ok { + return agentuid.AgentUID, nil + } + euid := geteuid() + // os.Geteuid returns -1 where the syscall is unavailable; a blind uint32 + // conversion would wrap it to a huge uid. The non-root check downstream + // (spec.go) refuses euid 0, so a root Runner is refused at startup too. + if euid < 0 { + return 0, fmt.Errorf("host backend: geteuid returned %d, no usable effective uid to run agents as", euid) + } + return uint32(euid), nil //nolint:gosec // G115: euid is non-negative here (the < 0 case returned above), so the narrowing cannot wrap. +} + +// newSpecBuilder assembles the config spec builder from the resolved engine and +// operator inputs. The uid every workspace runs as is derived per backend +// (specDefaultsUID); everything else is the operator's flags/env verbatim. +func newSpecBuilder(engine runtime.WorkloadRuntime, image string, egress runtime.EgressPolicy, checkoutDir, homeDir string, mounts []runtime.Mount) (runner.SpecBuilder, error) { + uid, err := specDefaultsUID(engine, os.Geteuid) + if err != nil { + return nil, err + } + return runner.NewConfigSpecBuilder(runner.SpecDefaults{ + Image: image, + Egress: egress, + CheckoutDir: checkoutDir, + HomeDir: homeDir, + UID: uid, + NamePrefix: runner.AgentContainerNamePrefix, + Mounts: mounts, + }) +} + // microVMPreflighter is the microVM backend's static host-capability probe: // the host trio (VMM/virtiofsd binaries), KVM access, and guest-image presence. type microVMPreflighter interface { diff --git a/go/cmd/compass-runner/uid_test.go b/go/cmd/compass-runner/uid_test.go new file mode 100644 index 000000000..cd816584e --- /dev/null +++ b/go/cmd/compass-runner/uid_test.go @@ -0,0 +1,133 @@ +//go:build unix + +package main + +import ( + "errors" + "os" + "strconv" + "strings" + "testing" + + "github.com/RigelBuild/compass/go/internal/agentuid" + "github.com/RigelBuild/compass/go/internal/runner" + "github.com/RigelBuild/compass/go/internal/runtime" +) + +// TestSpecDefaultsUIDHostDerivesFromEuid: on the host backend the workspace uid +// is the Runner's own effective uid, read through the geteuid seam, never the +// baked fleet constant. The seam returns a value distinct from AgentUID so the +// test discriminates: the pre-T1a code handed AgentUID here. +func TestSpecDefaultsUIDHostDerivesFromEuid(t *testing.T) { + const euid = 4242 + uid, err := specDefaultsUID(runtime.NewHostRuntime(t.TempDir()), func() int { return euid }) + if err != nil { + t.Fatalf("specDefaultsUID(host) err = %v, want nil", err) + } + if uid != euid { + t.Fatalf("specDefaultsUID(host) = %d, want the derived euid %d", uid, euid) + } + if uid == agentuid.AgentUID { + t.Fatalf("specDefaultsUID(host) = %d, must not be the baked fleet constant", uid) + } +} + +// TestSpecDefaultsUIDContainerTiersKeepConstant: the podman/microvm/ +// apple-container tiers keep AgentUID byte-identically — their userns remap maps +// the invoking host uid onto the baked 1000, so the geteuid seam is never +// consulted (it panics if it is). +func TestSpecDefaultsUIDContainerTiersKeepConstant(t *testing.T) { + neverCalled := func() int { + t.Fatal("geteuid must not be read for a container backend") + return 0 + } + engines := map[string]runtime.WorkloadRuntime{ + "podman": runtime.NewPodmanCLI(), + "microvm": runtime.NewMicroVMRuntime(runtime.MicroVMConfig{}), + "apple-container": runtime.NewAppleContainerCLI(runtime.AppleContainerConfig{}), + } + for name, engine := range engines { + t.Run(name, func(t *testing.T) { + uid, err := specDefaultsUID(engine, neverCalled) + if err != nil { + t.Fatalf("specDefaultsUID(%s) err = %v, want nil", name, err) + } + if uid != agentuid.AgentUID { + t.Fatalf("specDefaultsUID(%s) = %d, want AgentUID %d unchanged", name, uid, agentuid.AgentUID) + } + }) + } +} + +// TestSpecDefaultsUIDNegativeEuidRefused: os.Geteuid returns -1 where the +// syscall is unavailable; the derivation must refuse it rather than wrap it into +// a huge uint32. Reachable only through the seam. +func TestSpecDefaultsUIDNegativeEuidRefused(t *testing.T) { + _, err := specDefaultsUID(runtime.NewHostRuntime(t.TempDir()), func() int { return -1 }) + if err == nil { + t.Fatal("specDefaultsUID(host, euid=-1) err = nil, want a refusal") + } + if !strings.Contains(err.Error(), "-1") { + t.Fatalf("error %q does not name the offending euid", err) + } +} + +// TestSpecDefaultsUIDRootRefusedAtStartup: a host Runner whose euid is 0 is +// refused at startup by the existing non-root check, not at the first provision. +// The derivation itself returns 0 (root reaches the same check the container +// tiers do); NewConfigSpecBuilder is what refuses it. +func TestSpecDefaultsUIDRootRefusedAtStartup(t *testing.T) { + uid, err := specDefaultsUID(runtime.NewHostRuntime(t.TempDir()), func() int { return 0 }) + if err != nil { + t.Fatalf("specDefaultsUID(host, euid=0) err = %v, want nil (the non-root check refuses it)", err) + } + if uid != 0 { + t.Fatalf("specDefaultsUID(host, euid=0) = %d, want 0", uid) + } + _, err = runner.NewConfigSpecBuilder(runner.SpecDefaults{ + Image: "img", + CheckoutDir: "/workspace", + HomeDir: "/home/agent", + UID: uid, + NamePrefix: runner.AgentContainerNamePrefix, + }) + if err == nil { + t.Fatal("NewConfigSpecBuilder with a root uid err = nil, want a startup refusal") + } + if !strings.Contains(err.Error(), "non-root") { + t.Fatalf("error %q is not the non-root refusal", err) + } +} + +// TestHostDerivedUIDAcceptedByProvisionPath is the end-to-end regression this +// task exists to prevent: the uid the Runner derives for the host tier is +// exactly the uid the backend's AsUser rule accepts, so every provision-path +// exec (which passes Workspace.UID as AsUser) is accepted rather than rejected. +// A real HostRuntime captures os.Geteuid at construction; deriving through the +// same source ties the two together. +// +// This box's euid is not asserted to differ from AgentUID, so it does not +// discriminate the "euid != 1000" regression on a 1000-box — that acceptance +// needs a box whose euid is not 1000 (see the report). What it proves here is +// the contract closure: derived uid == the uid checkUser trusts. +func TestHostDerivedUIDAcceptedByProvisionPath(t *testing.T) { + host := runtime.NewHostRuntime(t.TempDir()) + uid, err := specDefaultsUID(host, os.Geteuid) + if err != nil { + t.Fatalf("specDefaultsUID(host) err = %v", err) + } + id, err := host.Create(t.Context(), runtime.WorkloadSpec{Name: "agent-provision"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := host.Start(t.Context(), id); err != nil { + t.Fatalf("Start: %v", err) + } + asUser := strconv.FormatUint(uint64(uid), 10) + if _, err := host.Exec(t.Context(), id, runtime.NewExecSpec("true").AsUser(asUser)); err != nil { + if _, ok := errors.AsType[*runtime.UnsupportedUserError](err); ok { + t.Fatalf("provision AsUser(%s) rejected by host backend: %v", asUser, err) + } + t.Fatalf("Exec AsUser(%s) = %v, want nil", asUser, err) + } +} From 3d66da34302a3bc1a9d48ded250fb0ce77828947 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 21:40:52 -0400 Subject: [PATCH 2/2] refactor(runner): resolve the workspace uid through a backend capability (RIG-3512) The derivation read the effective uid a second time, independently of the one the host backend captured at construction and enforces in checkUser. The two agreed only because the euid does not change during startup, so the contract closure was incidental rather than structural. The host backend now names its own workspace uid from that captured value, and the runner resolves it through a capability probe beside the non-root check that validates the result. A backend without the capability keeps the baked fleet constant, which is correct for the container tiers rather than an oversight, so this probe defaults where the preflight probe fails closed. This also moves per-backend policy out of the runner binary, which its own package doc says assembles flags and env and leaves seam logic to the runner package. Co-authored-by: Matt Wilkinson --- go/cmd/compass-runner/main.go | 30 ++----- go/cmd/compass-runner/uid_test.go | 99 ++------------------- go/internal/runner/resolve_uid_test.go | 106 +++++++++++++++++++++++ go/internal/runner/spec.go | 26 ++++++ go/internal/runtime/host_backend.go | 14 +++ go/internal/runtime/host_backend_test.go | 34 ++++++++ 6 files changed, 193 insertions(+), 116 deletions(-) create mode 100644 go/internal/runner/resolve_uid_test.go diff --git a/go/cmd/compass-runner/main.go b/go/cmd/compass-runner/main.go index e9ec2fcce..4b028ebc3 100644 --- a/go/cmd/compass-runner/main.go +++ b/go/cmd/compass-runner/main.go @@ -23,7 +23,6 @@ import ( "connectrpc.com/connect" - "github.com/RigelBuild/compass/go/internal/agentuid" "github.com/RigelBuild/compass/go/internal/otel" "github.com/RigelBuild/compass/go/internal/runner" "github.com/RigelBuild/compass/go/internal/runtime" @@ -174,31 +173,14 @@ func run() error { }, specs, log) } -// specDefaultsUID resolves the uid every agent workspace runs as, keyed off the -// already-resolved engine so the flag/env backend fallback is not re-derived. -// The host backend runs agents as direct children under the Runner's own uid, -// so it derives the euid via geteuid; every container tier keeps the baked fleet -// constant, which its userns remap maps the invoking host uid onto. geteuid is a -// seam so the narrowing edges are reachable in a test. -func specDefaultsUID(engine runtime.WorkloadRuntime, geteuid func() int) (uint32, error) { - if _, ok := engine.(*runtime.HostRuntime); !ok { - return agentuid.AgentUID, nil - } - euid := geteuid() - // os.Geteuid returns -1 where the syscall is unavailable; a blind uint32 - // conversion would wrap it to a huge uid. The non-root check downstream - // (spec.go) refuses euid 0, so a root Runner is refused at startup too. - if euid < 0 { - return 0, fmt.Errorf("host backend: geteuid returned %d, no usable effective uid to run agents as", euid) - } - return uint32(euid), nil //nolint:gosec // G115: euid is non-negative here (the < 0 case returned above), so the narrowing cannot wrap. -} - // newSpecBuilder assembles the config spec builder from the resolved engine and -// operator inputs. The uid every workspace runs as is derived per backend -// (specDefaultsUID); everything else is the operator's flags/env verbatim. +// operator inputs. The uid every workspace runs as is resolved per backend by +// runner.ResolveWorkspaceUID (the host tier names its own euid; the container +// tiers keep the baked fleet constant); everything else is the operator's +// flags/env verbatim. The per-backend uid policy lives in internal/runner beside +// the non-root check that validates the result — this binary stays a thin wrapper. func newSpecBuilder(engine runtime.WorkloadRuntime, image string, egress runtime.EgressPolicy, checkoutDir, homeDir string, mounts []runtime.Mount) (runner.SpecBuilder, error) { - uid, err := specDefaultsUID(engine, os.Geteuid) + uid, err := runner.ResolveWorkspaceUID(engine) if err != nil { return nil, err } diff --git a/go/cmd/compass-runner/uid_test.go b/go/cmd/compass-runner/uid_test.go index cd816584e..837e41088 100644 --- a/go/cmd/compass-runner/uid_test.go +++ b/go/cmd/compass-runner/uid_test.go @@ -4,107 +4,22 @@ package main import ( "errors" - "os" "strconv" - "strings" "testing" - "github.com/RigelBuild/compass/go/internal/agentuid" "github.com/RigelBuild/compass/go/internal/runner" "github.com/RigelBuild/compass/go/internal/runtime" ) -// TestSpecDefaultsUIDHostDerivesFromEuid: on the host backend the workspace uid -// is the Runner's own effective uid, read through the geteuid seam, never the -// baked fleet constant. The seam returns a value distinct from AgentUID so the -// test discriminates: the pre-T1a code handed AgentUID here. -func TestSpecDefaultsUIDHostDerivesFromEuid(t *testing.T) { - const euid = 4242 - uid, err := specDefaultsUID(runtime.NewHostRuntime(t.TempDir()), func() int { return euid }) - if err != nil { - t.Fatalf("specDefaultsUID(host) err = %v, want nil", err) - } - if uid != euid { - t.Fatalf("specDefaultsUID(host) = %d, want the derived euid %d", uid, euid) - } - if uid == agentuid.AgentUID { - t.Fatalf("specDefaultsUID(host) = %d, must not be the baked fleet constant", uid) - } -} - -// TestSpecDefaultsUIDContainerTiersKeepConstant: the podman/microvm/ -// apple-container tiers keep AgentUID byte-identically — their userns remap maps -// the invoking host uid onto the baked 1000, so the geteuid seam is never -// consulted (it panics if it is). -func TestSpecDefaultsUIDContainerTiersKeepConstant(t *testing.T) { - neverCalled := func() int { - t.Fatal("geteuid must not be read for a container backend") - return 0 - } - engines := map[string]runtime.WorkloadRuntime{ - "podman": runtime.NewPodmanCLI(), - "microvm": runtime.NewMicroVMRuntime(runtime.MicroVMConfig{}), - "apple-container": runtime.NewAppleContainerCLI(runtime.AppleContainerConfig{}), - } - for name, engine := range engines { - t.Run(name, func(t *testing.T) { - uid, err := specDefaultsUID(engine, neverCalled) - if err != nil { - t.Fatalf("specDefaultsUID(%s) err = %v, want nil", name, err) - } - if uid != agentuid.AgentUID { - t.Fatalf("specDefaultsUID(%s) = %d, want AgentUID %d unchanged", name, uid, agentuid.AgentUID) - } - }) - } -} - -// TestSpecDefaultsUIDNegativeEuidRefused: os.Geteuid returns -1 where the -// syscall is unavailable; the derivation must refuse it rather than wrap it into -// a huge uint32. Reachable only through the seam. -func TestSpecDefaultsUIDNegativeEuidRefused(t *testing.T) { - _, err := specDefaultsUID(runtime.NewHostRuntime(t.TempDir()), func() int { return -1 }) - if err == nil { - t.Fatal("specDefaultsUID(host, euid=-1) err = nil, want a refusal") - } - if !strings.Contains(err.Error(), "-1") { - t.Fatalf("error %q does not name the offending euid", err) - } -} - -// TestSpecDefaultsUIDRootRefusedAtStartup: a host Runner whose euid is 0 is -// refused at startup by the existing non-root check, not at the first provision. -// The derivation itself returns 0 (root reaches the same check the container -// tiers do); NewConfigSpecBuilder is what refuses it. -func TestSpecDefaultsUIDRootRefusedAtStartup(t *testing.T) { - uid, err := specDefaultsUID(runtime.NewHostRuntime(t.TempDir()), func() int { return 0 }) - if err != nil { - t.Fatalf("specDefaultsUID(host, euid=0) err = %v, want nil (the non-root check refuses it)", err) - } - if uid != 0 { - t.Fatalf("specDefaultsUID(host, euid=0) = %d, want 0", uid) - } - _, err = runner.NewConfigSpecBuilder(runner.SpecDefaults{ - Image: "img", - CheckoutDir: "/workspace", - HomeDir: "/home/agent", - UID: uid, - NamePrefix: runner.AgentContainerNamePrefix, - }) - if err == nil { - t.Fatal("NewConfigSpecBuilder with a root uid err = nil, want a startup refusal") - } - if !strings.Contains(err.Error(), "non-root") { - t.Fatalf("error %q is not the non-root refusal", err) - } -} - // TestHostDerivedUIDAcceptedByProvisionPath is the end-to-end regression this // task exists to prevent: the uid the Runner derives for the host tier is // exactly the uid the backend's AsUser rule accepts, so every provision-path // exec (which passes Workspace.UID as AsUser) is accepted rather than rejected. -// A real HostRuntime captures os.Geteuid at construction; deriving through the -// same source ties the two together. +// +// The derivation and the AsUser rule now close structurally: ResolveWorkspaceUID +// returns HostRuntime.WorkspaceUID(), which reports the euid the backend captured +// at construction — the very same h.euid checkUser enforces. Both read one +// captured value, not two independent geteuid syscalls that agreed by accident. // // This box's euid is not asserted to differ from AgentUID, so it does not // discriminate the "euid != 1000" regression on a 1000-box — that acceptance @@ -112,9 +27,9 @@ func TestSpecDefaultsUIDRootRefusedAtStartup(t *testing.T) { // the contract closure: derived uid == the uid checkUser trusts. func TestHostDerivedUIDAcceptedByProvisionPath(t *testing.T) { host := runtime.NewHostRuntime(t.TempDir()) - uid, err := specDefaultsUID(host, os.Geteuid) + uid, err := runner.ResolveWorkspaceUID(host) if err != nil { - t.Fatalf("specDefaultsUID(host) err = %v", err) + t.Fatalf("ResolveWorkspaceUID(host) err = %v", err) } id, err := host.Create(t.Context(), runtime.WorkloadSpec{Name: "agent-provision"}) if err != nil { diff --git a/go/internal/runner/resolve_uid_test.go b/go/internal/runner/resolve_uid_test.go new file mode 100644 index 000000000..a45af74b9 --- /dev/null +++ b/go/internal/runner/resolve_uid_test.go @@ -0,0 +1,106 @@ +//go:build unix + +package runner + +// ResolveWorkspaceUID: the per-backend uid policy that lives beside the non-root +// check. A backend that names its own uid (the host tier) wins; every container +// tier falls back to the baked AgentUID. These moved here from the cmd binary +// when the resolution moved off a concrete type assertion onto a capability +// probe on the runtime. + +import ( + "errors" + "strings" + "testing" + + "github.com/RigelBuild/compass/go/internal/agentuid" + "github.com/RigelBuild/compass/go/internal/runtime" +) + +// fixedUIDBackend is a WorkloadRuntime that implements the workspaceUIDResolver +// capability with a fixed answer, so the resolver's probe hit (and its error and +// root paths) are exercisable without a real host euid, which this box cannot set +// to 0 or -1. +type fixedUIDBackend struct { + *pipeRuntime + uid uint32 + err error +} + +func (b fixedUIDBackend) WorkspaceUID() (uint32, error) { return b.uid, b.err } + +// TestResolveWorkspaceUIDCapabilityWins: a backend implementing the capability +// has its WorkspaceUID honored, so the resolved uid is the backend's own value, +// never the baked fleet constant. The fake reports a uid distinct from AgentUID +// so the probe-over-default behavior discriminates — a resolver that ignored the +// capability and returned AgentUID would fail here. The real host backend proves +// the euid-capture closure end to end in the cmd package. +func TestResolveWorkspaceUIDCapabilityWins(t *testing.T) { + const backendUID = 4242 + uid, err := ResolveWorkspaceUID(fixedUIDBackend{pipeRuntime: newPipeRuntime(), uid: backendUID}) + if err != nil { + t.Fatalf("ResolveWorkspaceUID(capability backend) err = %v, want nil", err) + } + if uid != backendUID { + t.Fatalf("ResolveWorkspaceUID(capability backend) = %d, want the backend's uid %d", uid, backendUID) + } + if uid == agentuid.AgentUID { + t.Fatalf("ResolveWorkspaceUID = %d, must not be the baked fleet constant", uid) + } +} + +// TestResolveWorkspaceUIDContainerTiersKeepConstant: the podman/microvm/ +// apple-container tiers do not implement the capability, so the resolver falls +// back to AgentUID byte-identically — their userns remap maps the invoking host +// uid onto the baked 1000. +func TestResolveWorkspaceUIDContainerTiersKeepConstant(t *testing.T) { + engines := map[string]runtime.WorkloadRuntime{ + "podman": runtime.NewPodmanCLI(), + "microvm": runtime.NewMicroVMRuntime(runtime.MicroVMConfig{}), + "apple-container": runtime.NewAppleContainerCLI(runtime.AppleContainerConfig{}), + } + for name, engine := range engines { + t.Run(name, func(t *testing.T) { + uid, err := ResolveWorkspaceUID(engine) + if err != nil { + t.Fatalf("ResolveWorkspaceUID(%s) err = %v, want nil", name, err) + } + if uid != agentuid.AgentUID { + t.Fatalf("ResolveWorkspaceUID(%s) = %d, want AgentUID %d unchanged", name, uid, agentuid.AgentUID) + } + }) + } +} + +// TestResolveWorkspaceUIDPropagatesRefusal: a backend whose WorkspaceUID reports +// the no-syscall case (geteuid unavailable) is refused, not wrapped into a huge +// uid — the resolver returns the backend's error verbatim rather than swallowing +// it and defaulting to AgentUID. +func TestResolveWorkspaceUIDPropagatesRefusal(t *testing.T) { + refusal := errors.New("host backend: geteuid returned -1, no usable effective uid to run agents as") + _, err := ResolveWorkspaceUID(fixedUIDBackend{pipeRuntime: newPipeRuntime(), err: refusal}) + if !errors.Is(err, refusal) { + t.Fatalf("ResolveWorkspaceUID(refusing backend) err = %v, want the backend's refusal", err) + } +} + +// TestResolveWorkspaceUIDRootRefusedAtStartup: a backend resolving to uid 0 +// reaches the pre-existing non-root refusal in NewConfigSpecBuilder at startup, +// not at the first provision. The resolver itself returns 0 (root reaches the +// same check the container tiers do); the constructor is what refuses it. +func TestResolveWorkspaceUIDRootRefusedAtStartup(t *testing.T) { + uid, err := ResolveWorkspaceUID(fixedUIDBackend{pipeRuntime: newPipeRuntime(), uid: 0}) + if err != nil { + t.Fatalf("ResolveWorkspaceUID(uid=0) err = %v, want nil (the non-root check refuses it)", err) + } + if uid != 0 { + t.Fatalf("ResolveWorkspaceUID(uid=0) = %d, want 0", uid) + } + d := goodDefaults() + d.UID = uid + if _, err := NewConfigSpecBuilder(d); err == nil { + t.Fatal("NewConfigSpecBuilder with a root uid err = nil, want a startup refusal") + } else if !strings.Contains(err.Error(), "non-root") { + t.Fatalf("error %q is not the non-root refusal", err) + } +} diff --git a/go/internal/runner/spec.go b/go/internal/runner/spec.go index 99b53bb8b..560aa1eaa 100644 --- a/go/internal/runner/spec.go +++ b/go/internal/runner/spec.go @@ -13,6 +13,7 @@ import ( "strings" compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/agentuid" "github.com/RigelBuild/compass/go/internal/runtime" ) @@ -74,6 +75,31 @@ func NewConfigSpecBuilder(defaults SpecDefaults) (SpecBuilder, error) { return &configSpecBuilder{defaults: defaults}, nil } +// workspaceUIDResolver is the backend capability of naming the uid its agent +// workspaces run as. The host backend implements it (it runs agents as direct +// children under the Runner's own euid, so the uid is that euid); the container +// tiers do not, because their userns remap maps the invoking host uid onto the +// baked fleet constant, so they need no per-Runner uid. +type workspaceUIDResolver interface { + WorkspaceUID() (uint32, error) +} + +// ResolveWorkspaceUID resolves the uid every agent workspace runs as, keyed off +// the resolved engine. A backend that names its own uid (the host tier) wins; +// every other backend falls back to agentuid.AgentUID. +// +// Unlike verifyBackendPreflight's fail-closed default, an unrecognized backend +// here is NOT an error: the container tiers legitimately do not implement this +// capability, so AgentUID is the correct, deliberate default for them — not an +// oversight. The result feeds NewConfigSpecBuilder's non-root check below, which +// refuses a root uid at startup whichever branch produced it. +func ResolveWorkspaceUID(engine runtime.WorkloadRuntime) (uint32, error) { + if r, ok := engine.(workspaceUIDResolver); ok { + return r.WorkspaceUID() + } + return agentuid.AgentUID, nil +} + // BuildSpec maps the request's agent account onto a full AgentSpec, filling // image/egress/workspace-layout from the defaults. func (b *configSpecBuilder) BuildSpec(req *compassv1.ProvisionAgentWorkspaceRequest) (runtime.AgentSpec, error) { diff --git a/go/internal/runtime/host_backend.go b/go/internal/runtime/host_backend.go index 6d5567cac..61b6d1569 100644 --- a/go/internal/runtime/host_backend.go +++ b/go/internal/runtime/host_backend.go @@ -137,6 +137,20 @@ func NewHostRuntime(stateRoot string) *HostRuntime { } } +// WorkspaceUID resolves the uid host-backend agents run as: the Runner's own +// effective uid captured at construction — the same h.euid checkUser enforces — +// so the uid handed to each workspace is exactly the uid its execs will be +// accepted under. os.Geteuid returns -1 where the syscall is unavailable; a +// blind uint32 conversion would wrap it to a huge uid, so the negative case is +// refused before any narrowing. The error wording is stable so the startup +// failure text does not regress. +func (h *HostRuntime) WorkspaceUID() (uint32, error) { + if h.euid < 0 { + return 0, fmt.Errorf("host backend: geteuid returned %d, no usable effective uid to run agents as", h.euid) + } + return uint32(h.euid), nil //nolint:gosec // G115: euid is non-negative here (the < 0 case returned above), so the narrowing cannot wrap. +} + // WithTimeout overrides the per-command wall-clock cap Exec applies. func (h *HostRuntime) WithTimeout(timeout time.Duration) *HostRuntime { h.timeout = timeout diff --git a/go/internal/runtime/host_backend_test.go b/go/internal/runtime/host_backend_test.go index ad2e14fc1..bf9f3b525 100644 --- a/go/internal/runtime/host_backend_test.go +++ b/go/internal/runtime/host_backend_test.go @@ -467,3 +467,37 @@ func TestHostExecStreamingRemovedDuringSpawn(t *testing.T) { t.Fatalf("err = %v, want the removed-during-spawn refusal", err) } } + +// TestHostWorkspaceUIDIsCapturedEuid: WorkspaceUID reports the euid captured at +// construction — the same value checkUser enforces — so the uid handed to a +// workspace is exactly the uid its execs are accepted under. Injects a known +// non-zero, non-AgentUID euid so the value discriminates a stubbed constant. +func TestHostWorkspaceUIDIsCapturedEuid(t *testing.T) { + const euid = 4242 + h := &HostRuntime{euid: euid} + uid, err := h.WorkspaceUID() + if err != nil { + t.Fatalf("WorkspaceUID() err = %v, want nil", err) + } + if uid != euid { + t.Fatalf("WorkspaceUID() = %d, want the captured euid %d", uid, euid) + } + // The uid must be exactly the value checkUser accepts. + if err := h.checkUser(new(strconv.FormatUint(uint64(uid), 10))); err != nil { + t.Fatalf("checkUser(%d) = %v, want the resolved uid accepted", uid, err) + } +} + +// TestHostWorkspaceUIDNegativeEuidRefused: os.Geteuid returns -1 where the +// syscall is unavailable; WorkspaceUID must refuse it rather than wrap it into a +// huge uint32, naming the offending euid so the startup failure is actionable. +func TestHostWorkspaceUIDNegativeEuidRefused(t *testing.T) { + h := &HostRuntime{euid: -1} + _, err := h.WorkspaceUID() + if err == nil { + t.Fatal("WorkspaceUID() with euid=-1 err = nil, want a refusal") + } + if !strings.Contains(err.Error(), "-1") { + t.Fatalf("error %q does not name the offending euid", err) + } +}