diff --git a/go/internal/runner/agent_exec.go b/go/internal/runner/agent_exec.go index 402a320d3..cf8bc78af 100644 --- a/go/internal/runner/agent_exec.go +++ b/go/internal/runner/agent_exec.go @@ -65,6 +65,15 @@ type AgentEnv struct { // ResumeSessionFile is the absolute in-container path of the materialized // resume session file, or empty for a fresh start. ResumeSessionFile string + // SocketPath, when non-empty, overrides the agent's default gateway-socket + // path (agent-side AGENT_SOCKET_PATH). ConfigMountPath likewise overrides the + // default config-mount root (AGENT_CONFIG_MOUNT_PATH). Both are empty on the + // container tiers, which deliver the socket + config at the frozen paths by + // bind mount; the host-process tier has no mounts, so it serves both inside + // the handle's own state dir and threads the paths here. Empty is omitted, so + // a container-tier agent receives neither var and resolves the frozen defaults. + SocketPath string + ConfigMountPath string } // execSpec builds the streaming exec that starts the agent: unprivileged, in @@ -92,6 +101,12 @@ func (e AgentEnv) execSpec() runtime.StreamingExecSpec { if e.ResumeSessionFile != "" { spec.Env["COMPASS_RESUME_SESSION_FILE"] = e.ResumeSessionFile } + if e.SocketPath != "" { + spec.Env["COMPASS_AGENT_SOCKET_PATH"] = e.SocketPath + } + if e.ConfigMountPath != "" { + spec.Env["COMPASS_AGENT_CONFIG_MOUNT_PATH"] = e.ConfigMountPath + } return spec } diff --git a/go/internal/runner/gateway/socket.go b/go/internal/runner/gateway/socket.go index d7b4833be..50cb0b79e 100644 --- a/go/internal/runner/gateway/socket.go +++ b/go/internal/runner/gateway/socket.go @@ -142,10 +142,13 @@ func listenAgentSocket(ctx context.Context, path string, h http.Handler, cancel // The bind's own error is "bind: invalid argument", an EINVAL naming neither // the limit nor the actual length, which reads as a permissions or path // problem. Check first, before any directory is created, so a misconfigured - // deployment is self-diagnosing at Provision and leaves nothing behind. The - // message names both knobs: the path alone does not say which one to shrink. + // deployment is self-diagnosing at Provision and leaves nothing behind. + // The message names the socket's parent, not a specific flag: the tiers root + // this path differently — the container tiers under the Runner's runtime dir, + // the host tier under the backend's state root — so naming one knob would + // send half the operators to a value that has no effect on their path. if len(path) > sunPathMax { - return nil, fmt.Errorf("agent socket path %q is %d bytes, over the %d-byte AF_UNIX limit: shorten the Runner's --runtime-dir or the agent account id: %w", path, len(path), sunPathMax, ErrOperatorConfig) + return nil, fmt.Errorf("agent socket path %q is %d bytes, over the %d-byte AF_UNIX limit: shorten the socket's parent directory or the agent account id: %w", path, len(path), sunPathMax, ErrOperatorConfig) } dir := filepath.Dir(path) diff --git a/go/internal/runner/gateway/socket_test.go b/go/internal/runner/gateway/socket_test.go index 133314173..75cc7218a 100644 --- a/go/internal/runner/gateway/socket_test.go +++ b/go/internal/runner/gateway/socket_test.go @@ -443,15 +443,17 @@ func TestRejectsPathOverSunPathLimit(t *testing.T) { t.Fatal("listen on an over-long path must fail, got nil error") } // The diagnostic is the whole point: the message must carry both numbers - // and the flag to change, none of which the kernel's EINVAL does. Match - // the surrounding phrases, not the bare digits — a temp path carries - // random digits that could contain the cap by chance and pass a - // substring check against a message that never mentioned it. + // and something actionable to shorten, none of which the kernel's EINVAL + // does. Match the surrounding phrases, not the bare digits — a temp path + // carries random digits that could contain the cap by chance and pass a + // substring check against a message that never mentioned it. The remedy + // names the socket's parent, not one tier's flag: this function is + // shared, and the tiers root the path under different knobs. msg := err.Error() for _, want := range []string{ fmt.Sprintf("is %d bytes", len(path)), fmt.Sprintf("over the %d-byte", sunPathMax), - "--runtime-dir", + "shorten the socket's parent directory", } { if !strings.Contains(msg, want) { t.Errorf("error %q does not contain %q", msg, want) diff --git a/go/internal/runner/host.go b/go/internal/runner/host.go index 37e19c507..42478df3d 100644 --- a/go/internal/runner/host.go +++ b/go/internal/runner/host.go @@ -59,6 +59,29 @@ type vsockGatewayEngine interface { AgentGatewayEndpoint(name string) (endpoint string, ok bool) } +// hostStateEngine is the unexported backend probe the host-process runtime +// (HostRuntime) satisfies: each agent runs as a direct host child with NO bind +// mounts, so its gateway socket and config tree are served inside the handle's +// own private 0700 state dir and threaded to the agent as env vars, not mounted +// at the frozen /run/compass paths. Provision type-asserts h.engine against it +// to gate the host-specific leg; podman, the microVM backend, and every test +// fake lack AgentStateDir, so their paths stay byte-identical (mirroring +// vsockGatewayEngine — never a verb on the frozen WorkloadRuntime interface). +type hostStateEngine interface { + AgentStateDir(id runtime.WorkloadID) (dir string, ok bool) +} + +// hostAgentTransport is the per-agent socket + config-root paths the host leg +// serves inside the handle's state dir and threads to the agent as env vars — +// the host tier's stand-in for the container tiers' fixed bind-mount paths, +// keyed by container name. Its presence also reroutes configMaterializerFor to +// the state-dir root so a later ConfigVersion refresh materializes where the +// agent actually reads. +type hostAgentTransport struct { + socketPath string + configRoot string +} + // agentHost is the production SessionHost. It owns the live session set and // drives the container lifecycle through the AgentRuntime registry + the relay. type agentHost struct { @@ -103,6 +126,13 @@ type agentHost struct { // the session set under h.mu), so it never queues behind a slow Provision. // See docs/designs/infra/runtime/compass-runner-concurrent-dispatch/design.md. containerLocks map[string]*sync.Mutex + // hostTransports records the per-agent socket + config-root paths the host + // backend's Provision leg serves inside each handle's state dir, keyed by + // container name. Empty for the podman and microVM tiers (which mount the + // socket/config at fixed paths); on the host tier it is the source agentEnv + // threads as env vars and configMaterializerFor reads to root a refresh. + // Set at Provision, removed at teardown (closeSocket). + hostTransports map[string]hostAgentTransport } // liveSession is one running agent session: its container and the relay stream @@ -158,6 +188,7 @@ func NewSessionHost(link *ServerLink, rt *runtime.AgentRuntime, registry *runtim materializer: runtime.NewSecretMaterializer(engine, log), configVersions: map[string]string{}, containerLocks: map[string]*sync.Mutex{}, + hostTransports: map[string]hostAgentTransport{}, } } @@ -191,6 +222,15 @@ func (h *agentHost) Provision(ctx context.Context, req *compassv1.ProvisionAgent if vsockEngine, ok := h.engine.(vsockGatewayEngine); ok { return h.provisionVsockGateway(ctx, spec, vsockEngine) } + // The host-process backend runs each agent as a direct child with NO bind + // mounts, so its socket + config live inside the handle's own state dir and + // are threaded to the agent as env vars, not mounted. That dir is minted by + // Create inside Launch, so this leg — like the vsock one — inverts the order: + // Launch first, then serve. Probe absent (podman, every fake) keeps today's + // body byte-identical. + if hostEngine, ok := h.engine.(hostStateEngine); ok { + return h.provisionHostGateway(ctx, spec, hostEngine) + } listener, err := h.serveSocket(ctx, spec.Name) if err != nil { return "", err @@ -834,6 +874,68 @@ func (h *agentHost) provisionVsockGateway(ctx context.Context, spec runtime.Agen return name, nil } +// provisionHostGateway is Provision's host-process leg: the agent runs as a +// direct host child with NO bind mounts, so its gateway socket and config tree +// live inside the handle's own private 0700 state dir and are threaded to the +// agent as env vars (agentEnv) rather than mounted at the frozen /run/compass +// paths. Like the vsock leg it inverts the order — Launch first, because Create +// mints the state dir, so there is nothing to serve into until the container +// exists — and appends NO mounts. A serve or config-materialize failure after a +// successful Launch tears BOTH the socket and the launched container down (the +// exact legs Remove/teardownContainer use), so no agent outlives a session whose +// transport never came up and no listener leaks. The config version is seeded +// exactly as the default leg does, so a later RefreshConfig only Reloads when +// the bundle actually moved past what this installed. +func (h *agentHost) provisionHostGateway(ctx context.Context, spec runtime.AgentSpec, engine hostStateEngine) (string, error) { + handle, err := h.runtime.Launch(ctx, spec) + if err != nil { + return "", err + } + name := handle.Name() + stateDir, ok := engine.AgentStateDir(handle.ID()) + if !ok { + h.teardownContainer(ctx, name) + return "", fmt.Errorf("resolving host state dir for container %q: backend reports no handle", name) + } + // The socket lands in the handle's 0700 socket subdir, which Create mints. + // The config tree is rooted under the same state dir but its root is created + // 0755 by the materializer, so the agent can traverse it; the 0700 state dir + // above it is what keeps it private. Record both paths BEFORE materializing: + // configMaterializerFor reads them to root the config tree in the state dir, + // and agentEnv reads them to thread the overrides onto the agent exec. + transport := hostAgentTransport{ + socketPath: filepath.Join(stateDir, "socket", agentSocketFile), + configRoot: filepath.Join(stateDir, "config"), + } + h.mu.Lock() + h.hostTransports[name] = transport + h.mu.Unlock() + if _, err := h.serveSocketAt(ctx, name, transport.socketPath); err != nil { + // The socket never came up; forget the transport (closeSocket) and tear + // the launched container down so no agent runs with no reachable Runner. + h.closeSocket(ctx, name) + h.teardownContainer(ctx, name) + return "", err + } + // Materialize the fleet config into the state-dir root. mcsLabel is empty: + // there is no container and no MCS category (the host backend's MountLabel + // returns ""), so Materialize takes its skip-chcon path and the agent reads + // the tree as the same uid that wrote it. + mount, err := h.configMaterializerFor(name).Materialize(ctx, "") + if err != nil { + // Config could not be materialized: tear the socket down (mirror the + // container leg's Launch-failure cleanup) so it does not leak, and tear + // the launched container down so no agent comes up with no config. + h.closeSocket(ctx, name) + h.teardownContainer(ctx, name) + return "", fmt.Errorf("materializing agent config: %w", err) + } + h.mu.Lock() + h.configVersions[name] = mount.Version + h.mu.Unlock() + return name, nil +} + // teardownContainer tears a just-launched container down through the runtime // Teardown (stop + remove + deregister) — the exact leg Remove uses — for the // provision-failure cleanup on the vsock path. A resolve miss or teardown error @@ -972,7 +1074,7 @@ func (h *agentHost) reloadLocked(ctx context.Context, sessionID string) error { // launched container's handle, so Start and Reload cannot drift apart. The // model is Runner-wide config; everything else is per-container. func (h *agentHost) agentEnv(handle *runtime.AgentHandle) AgentEnv { - return AgentEnv{ + env := AgentEnv{ UID: handle.WorkspaceUID(), HomeDir: handle.HomeDir(), Workdir: handle.CheckoutDir(), @@ -980,6 +1082,19 @@ func (h *agentHost) agentEnv(handle *runtime.AgentHandle) AgentEnv { Persona: handle.Persona(), Role: handle.Role(), } + // On the host tier the socket and config live inside the handle's own state + // dir, not at the frozen /run/compass paths (there are no mounts). Thread + // those overrides so the agent dials/reads where the host leg served them. + // Absent for the container tiers, whose transports map has no entry — the + // agent then resolves the frozen defaults. + h.mu.Lock() + transport, ok := h.hostTransports[handle.Name()] + h.mu.Unlock() + if ok { + env.SocketPath = transport.socketPath + env.ConfigMountPath = transport.configRoot + } + return env } // configMaterializerFor builds a ConfigMaterializer rooted at the container's @@ -989,6 +1104,17 @@ func (h *agentHost) agentEnv(handle *runtime.AgentHandle) AgentEnv { // SELinux MCS category (:Z, podman.go mountArg); a shared root would be // re-stolen by each new container's relabel on an enforcing host. func (h *agentHost) configMaterializerFor(containerName string) *ConfigMaterializer { + // On the host tier the config tree lives inside the handle's own state dir + // (recorded in hostTransports at provision), not under RuntimeDir/containers + // — there are no mounts, so a later refresh must re-materialize where the + // agent actually reads. Absent an entry (the container tiers), root at the + // per-container RuntimeDir subtree as before. + h.mu.Lock() + transport, ok := h.hostTransports[containerName] + h.mu.Unlock() + if ok { + return NewConfigMaterializer(transport.configRoot, h.link, h.log) + } return NewConfigMaterializer(filepath.Join(h.runtimeDir, agentSocketDir, containerName, "config"), h.link, h.log) } @@ -999,13 +1125,21 @@ func (h *agentHost) configMaterializerFor(containerName string) *ConfigMateriali // Gateway forwards to the Server over the Runner's own RunnerService client // (the link), resolving the container to its bound session via this host. func (h *agentHost) serveSocket(ctx context.Context, containerName string) (*gateway.SocketListener, error) { + return h.serveSocketAt(ctx, containerName, filepath.Join(h.runtimeDir, agentSocketDir, containerName, agentSocketFile)) +} + +// serveSocketAt is serveSocket with an explicit socket path: the container tiers +// pass the fixed RuntimeDir/containers//agent.sock, the host tier +// passes a path inside the handle's own state dir (no mount reaches it). The +// idempotency + recording discipline is identical: a container already serving +// keeps its live listener, never double-served. +func (h *agentHost) serveSocketAt(ctx context.Context, containerName, path string) (*gateway.SocketListener, error) { h.mu.Lock() if listener, served := h.sockets[containerName]; served { h.mu.Unlock() return listener, nil } h.mu.Unlock() - path := filepath.Join(h.runtimeDir, agentSocketDir, containerName, agentSocketFile) listener, err := gateway.Serve(ctx, path, containerName, gateway.Deps{Sessions: h, Relay: h.link.client, Lifecycle: h.link.client, Events: h.link.client, Committer: h.link.client, Forge: h.link.client}) if err != nil { return nil, fmt.Errorf("serving agent socket for container %q: %w", containerName, err) @@ -1025,6 +1159,9 @@ func (h *agentHost) closeSocket(ctx context.Context, containerName string) { if ok { delete(h.sockets, containerName) } + // Forget the host-tier transport paths alongside the socket: they are the + // same per-container lifetime, so a re-Provision re-records fresh ones. + delete(h.hostTransports, containerName) h.mu.Unlock() if !ok { return diff --git a/go/internal/runner/host_gateway_test.go b/go/internal/runner/host_gateway_test.go new file mode 100644 index 000000000..5dd66858a --- /dev/null +++ b/go/internal/runner/host_gateway_test.go @@ -0,0 +1,273 @@ +//go:build unix + +package runner + +// agentHost's host-process Provision leg: when the engine satisfies the +// unexported hostStateEngine probe (AgentStateDir), Provision runs the host +// leg. The host tier has NO bind mounts, so the leg serves the per-agent gateway +// socket and materializes the config tree INSIDE the handle's own 0700 state dir +// and threads both paths to the agent as env vars on the streaming exec — never +// mounting them at the frozen /run/compass paths. A serve/materialize failure +// after Launch tears both the socket and the container down. Every case names a +// contract a plausible bug would break. + +import ( + "context" + "os" + "path/filepath" + "testing" + + "connectrpc.com/connect" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/gen/compass/v1/compassv1internalconnect" + "github.com/RigelBuild/compass/go/internal/runnertest" + "github.com/RigelBuild/compass/go/internal/runtime" +) + +// hostStateFakeRuntime is a WorkloadRuntime that ALSO implements the +// hostStateEngine probe (AgentStateDir), so agentHost drives its host-process +// Provision leg. Create mints a real 0700 state dir per container with the +// socket subdir the host backend's Create makes — the config root is left to +// the materializer, as it is in production — keyed by the synthetic id it +// returns, so AgentStateDir(handle.ID()) resolves the same dir +// the leg serves into. It embeds the stub so ExecStreaming drives a real +// terminatable child for Start/Stop. +type hostStateFakeRuntime struct { + *stubStreamingRuntime + stateRoot string + // stateDirs records the minted state dir per synthetic id, so a test can + // assert the leg served/materialized inside exactly that dir. + stateDirs map[runtime.WorkloadID]string + // missing, when true, makes AgentStateDir report ok=false for every id — the + // "backend reports no handle" resolve-miss path. + missing bool + // blockConfig, when true, makes Create leave a regular FILE at the state + // dir's config path, so the socket serves + // but the leg's later Materialize → ensureRoot MkdirAll hits ENOTDIR — the + // materialize-fails-after-socket-serves failure path. + blockConfig bool +} + +func newHostStateFakeRuntime(t *testing.T) *hostStateFakeRuntime { + t.Helper() + return &hostStateFakeRuntime{ + stubStreamingRuntime: newStubStreamingRuntime(t), + stateRoot: t.TempDir(), + stateDirs: map[runtime.WorkloadID]string{}, + } +} + +func (r *hostStateFakeRuntime) Create(_ context.Context, spec runtime.WorkloadSpec) (runtime.WorkloadID, error) { + id := runtime.WorkloadID(spec.Name) + dir := filepath.Join(r.stateRoot, spec.Name) + // Mirror the host backend's Create: private 0700 state dir. blockConfig makes + // the config path a regular file so a later Materialize fails after the + // socket is already serving; otherwise create both subdirs the leg uses. + if err := os.MkdirAll(filepath.Join(dir, "socket"), 0o700); err != nil { + return "", err + } + if r.blockConfig { + if err := os.WriteFile(filepath.Join(dir, "config"), []byte("x"), 0o600); err != nil { + return "", err + } + } + r.mu.Lock() + r.calls = append(r.calls, "create") + r.created = append(r.created, spec) + r.mu.Unlock() + r.stateDirs[id] = dir + return id, nil +} + +func (r *hostStateFakeRuntime) AgentStateDir(id runtime.WorkloadID) (string, bool) { + if r.missing { + return "", false + } + dir, ok := r.stateDirs[id] + return dir, ok +} + +// newHostGatewayFixture builds the concrete *agentHost over the host-state fake +// runtime whose ServerLink forwards to relay, returning the host and the engine. +// The transport wiring mirrors newVsockGatewayFixture. +func newHostGatewayFixture(t *testing.T, relay compassv1internalconnect.RunnerServiceHandler) (*agentHost, *hostStateFakeRuntime) { + t.Helper() + engine := newHostStateFakeRuntime(t) + registry := runtime.NewAgentRegistry() + rt := runtime.NewAgentRuntimeWithRegistry(engine, registry) + link := newLink(newRunnerServiceServer(t, relay)) + specs := &fakeSpecBuilder{spec: liveSpec()} + var n int + newID := func() string { n++; return "sess-" + string(rune('0'+n)) } + host := NewSessionHost(link, rt, registry, engine, specs, AgentHostConfig{RuntimeDir: t.TempDir()}, discardLoggerRunner(), newID) + return host.(*agentHost), engine +} + +// hostAgentHandle is the fixed agent-handle argument the host-leg tests +// provision with — a 32-hex account id the spec builder echoes onto the spec. +const hostAgentHandle = "0123456789abcdef0123456789abcdef" + +// TestHostProvisionServesInStateDirWithNoMounts pins the host leg: the spec +// reaching the engine carries NO agent-socket mount and NO config mount (a host +// process has none), the socket is served at a path INSIDE the handle's own +// state dir, and the config tree is materialized under that same state dir. The +// listener the host records serves the REAL generated handler, dialable over +// plain AF_UNIX. +func TestHostProvisionServesInStateDirWithNoMounts(t *testing.T) { + fake := &recordingRelay{} + h, engine := newHostGatewayFixture(t, fake) + ctx := context.Background() + + name, err := h.Provision(ctx, &compassv1.ProvisionAgentWorkspaceRequest{AgentHandle: hostAgentHandle}) + if err != nil { + t.Fatalf("Provision = %v, want success", err) + } + + // The spec that reached the engine carries only the workspace mount: the host + // leg appends neither the agent-socket mount nor the config mount — mirroring + // the vsock leg's no-refused-mount assertion (host_vsock_gateway_test.go). + created := engine.createdSpecs() + if len(created) != 1 { + t.Fatalf("engine created %d containers, want 1", len(created)) + } + for _, m := range created[0].Mounts { + if m.ContainerPath == agentSocketMountPath { + t.Fatalf("host provision appended the agent-socket mount %q; a host process has no mounts", m.ContainerPath) + } + if m.ContainerPath == agentConfigMountPath { + t.Fatalf("host provision appended the config mount %q; a host process has no mounts", m.ContainerPath) + } + } + + // The socket was served inside the handle's state dir, not at a RuntimeDir + // path — the whole point of the leg. + stateDir, ok := engine.AgentStateDir(runtime.WorkloadID(name)) + if !ok { + t.Fatal("fake engine has no state dir for the provisioned container") + } + wantSocket := filepath.Join(stateDir, "socket", agentSocketFile) + gotSocket := listenerPath(t, h, name) + if gotSocket != wantSocket { + t.Fatalf("recorded listener path = %q, want the state-dir socket %q", gotSocket, wantSocket) + } + + // The config tree was materialized under the state dir's config root (the + // unconfigured-fleet bundle still ensures the root exists). + wantConfigRoot := filepath.Join(stateDir, "config") + if info, statErr := os.Stat(wantConfigRoot); statErr != nil || !info.IsDir() { + t.Fatalf("config root %q not created under the state dir: err=%v", wantConfigRoot, statErr) + } + + // The recorded listener serves the real generated handler: a bound session + // round-trips over the state-dir socket. + sessionID, err := h.Start(ctx, &compassv1.StartAgentSessionRequest{ContainerName: name}, "") + if err != nil { + t.Fatalf("Start = %v", err) + } + t.Cleanup(func() { _ = h.Stop(context.Background(), sessionID) }) // cleanup: best-effort teardown of the started agent. + + client := runnertest.DialAgentSocket(t, gotSocket) + callCtx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + resp, err := client.Comms(callCtx, connect.NewRequest(&compassv1internal.CommsCallRequest{ + CallId: "hc-1", + Call: &compassv1internal.CommsCallRequest_Post{ + Post: &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: "chan-1"}}, + }, + })) + if err != nil { + t.Fatalf("Comms over the state-dir socket = %v, want the round-trip result", err) + } + if resp.Msg.GetCallId() != "hc-1" { + t.Fatalf("result call id = %q, want hc-1", resp.Msg.GetCallId()) + } +} + +// TestHostStartThreadsTransportEnvVars pins that the host leg threads BOTH the +// socket path and the config root onto the agent's streaming exec as env vars — +// pointing at paths inside the handle's own state dir — so the agent (which has +// no mounts) dials and reads where the leg served. The container tiers set +// neither var; here both must be present and state-dir-rooted. +func TestHostStartThreadsTransportEnvVars(t *testing.T) { + fake := &recordingRelay{} + h, engine := newHostGatewayFixture(t, fake) + ctx := context.Background() + + name, err := h.Provision(ctx, &compassv1.ProvisionAgentWorkspaceRequest{AgentHandle: hostAgentHandle}) + if err != nil { + t.Fatalf("Provision = %v", err) + } + sessionID, err := h.Start(ctx, &compassv1.StartAgentSessionRequest{ContainerName: name}, "") + if err != nil { + t.Fatalf("Start = %v", err) + } + t.Cleanup(func() { _ = h.Stop(context.Background(), sessionID) }) // cleanup: best-effort teardown of the started agent. + + stateDir, ok := engine.AgentStateDir(runtime.WorkloadID(name)) + if !ok { + t.Fatal("fake engine has no state dir for the provisioned container") + } + got := onlyStreamingSpec(t, engine.stubStreamingRuntime) + wantSocket := filepath.Join(stateDir, "socket", agentSocketFile) + if got.Env["COMPASS_AGENT_SOCKET_PATH"] != wantSocket { + t.Fatalf("agent exec COMPASS_AGENT_SOCKET_PATH = %q, want the state-dir socket %q", got.Env["COMPASS_AGENT_SOCKET_PATH"], wantSocket) + } + wantConfigRoot := filepath.Join(stateDir, "config") + if got.Env["COMPASS_AGENT_CONFIG_MOUNT_PATH"] != wantConfigRoot { + t.Fatalf("agent exec COMPASS_AGENT_CONFIG_MOUNT_PATH = %q, want the state-dir config root %q", got.Env["COMPASS_AGENT_CONFIG_MOUNT_PATH"], wantConfigRoot) + } +} + +// TestHostProvisionResolveMissTearsDownSession pins the resolve-miss leg: if the +// backend reports no state dir for the launched name, the launched container is +// still torn down and Provision errs — no agent runs with no reachable transport. +func TestHostProvisionResolveMissTearsDownSession(t *testing.T) { + fake := &recordingRelay{} + h, engine := newHostGatewayFixture(t, fake) + engine.missing = true + ctx := context.Background() + + _, err := h.Provision(ctx, &compassv1.ProvisionAgentWorkspaceRequest{AgentHandle: hostAgentHandle}) + if err == nil { + t.Fatal("Provision with an unresolvable state dir = nil, want an error") + } + assertRecorded(t, engine.calls, "stop") + assertRecorded(t, engine.calls, "remove") + if socketServed(t, h, liveSpec().Name) { + t.Fatal("a resolve miss left a recorded listener; nothing must be recorded on the failure path") + } +} + +// TestHostProvisionConfigMaterializeFailureTearsDownSocket pins the failure +// symmetry the design calls for: a config-materialize failure AFTER the socket +// is already serving tears the socket down (no leak) AND tears the launched +// container down. The materialize is forced to fail by pre-occupying the config +// root path with a regular FILE, so ensureRoot's MkdirAll hits ENOTDIR. +func TestHostProvisionConfigMaterializeFailureTearsDownSocket(t *testing.T) { + // A non-empty, MOVING bundle so Materialize takes the unpack path (which + // calls ensureRoot) rather than the unconfigured no-op. + pub := newCapturePublish() + pub.setConfigBundle(configBundleAt(t, "v-1")) + h, engine := newHostGatewayFixture(t, pub) + ctx := context.Background() + + // blockConfig makes the fake's Create leave a regular file at the config path, + // so the leg's Materialize → ensureRoot MkdirAll fails ENOTDIR AFTER the + // socket is already serving — the "materialize fails after serve" path. + engine.blockConfig = true + name := liveSpec().Name + + _, err := h.Provision(ctx, &compassv1.ProvisionAgentWorkspaceRequest{AgentHandle: hostAgentHandle}) + if err == nil { + t.Fatal("Provision with an unwritable config root = nil, want the materialize error") + } + // The socket served before the materialize must not leak. + if socketServed(t, h, name) { + t.Fatal("a failed config materialize left the socket served; it must be torn down") + } + // The launched container was torn down (stop + remove through Teardown). + assertRecorded(t, engine.calls, "stop") + assertRecorded(t, engine.calls, "remove") +} diff --git a/go/internal/runner/spec.go b/go/internal/runner/spec.go index 560aa1eaa..eb8145138 100644 --- a/go/internal/runner/spec.go +++ b/go/internal/runner/spec.go @@ -84,6 +84,12 @@ type workspaceUIDResolver interface { WorkspaceUID() (uint32, error) } +// Compile-time regression guard, mirroring the preflight probes' assertions: +// the binding is structural and cross-package, so a signature drift on either +// side would otherwise fall through to the AgentUID default below — silently, +// late (at first provision), and invisibly on a euid-1000 box. +var _ workspaceUIDResolver = (*runtime.HostRuntime)(nil) + // 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. diff --git a/go/internal/runnerhub/relay_operator_fault_test.go b/go/internal/runnerhub/relay_operator_fault_test.go index 2b0d328ca..733793c4f 100644 --- a/go/internal/runnerhub/relay_operator_fault_test.go +++ b/go/internal/runnerhub/relay_operator_fault_test.go @@ -34,7 +34,7 @@ func TestProvisionRelaySurfacesOperatorFaultAsFailedPrecondition(t *testing.T) { // the socket diagnostic followed by the appended gateway.ErrOperatorConfig // sentinel text. The Runner is simulated here, so the wire carries only the // string — the gateway package is deliberately not imported. - const diag = "serving agent socket for container \"cont-op\": agent socket path \"/run/compass/containers/cont-op/agent.sock\" is 120 bytes, over the 108-byte AF_UNIX limit: shorten the Runner's --runtime-dir or the agent account id: operator-fault runner configuration" + const diag = "serving agent socket for container \"cont-op\": agent socket path \"/run/compass/containers/cont-op/agent.sock\" is 120 bytes, over the 108-byte AF_UNIX limit: shorten the socket's parent directory or the agent account id: operator-fault runner configuration" router.attach(func(cmd *compassv1internal.SessionsResponse) error { go router.complete(&compassv1internal.SessionsRequest{ diff --git a/go/internal/runtime/host_backend.go b/go/internal/runtime/host_backend.go index 61b6d1569..e32073e26 100644 --- a/go/internal/runtime/host_backend.go +++ b/go/internal/runtime/host_backend.go @@ -471,6 +471,23 @@ func (h *HostRuntime) Resize(_ context.Context, _ WorkloadID, _ ResourceLimits) return ErrResizeUnsupportedOnHost } +// AgentStateDir returns the private 0700 state dir Create minted for the handle, +// where the Runner's host Provision leg serves the agent's gateway socket and +// materializes its config — the host tier has no bind mounts, so both live here +// and are threaded to the agent by path. An unknown id returns ok=false. This is +// the host backend's Provision-leg selector, the analogue of the microVM +// backend's AgentGatewayEndpoint: only HostRuntime implements it, so podman and +// the microVM backend leave the Runner's default leg byte-identical. +func (h *HostRuntime) AgentStateDir(id WorkloadID) (string, bool) { + h.mu.Lock() + defer h.mu.Unlock() + handle, ok := h.handles[id] + if !ok { + return "", false + } + return handle.stateDir, true +} + // 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 diff --git a/packages/compass-agent/src/cli.test.ts b/packages/compass-agent/src/cli.test.ts index 99ec7a85d..02c815a7e 100644 --- a/packages/compass-agent/src/cli.test.ts +++ b/packages/compass-agent/src/cli.test.ts @@ -53,6 +53,7 @@ import { resolveModelSelector, resolvePersona, resolveRole, + resolveSocketPath, } from "./cli"; import { CommsBroker, createCommsTools } from "./comms"; import { @@ -116,6 +117,26 @@ describe("AGENT_SOCKET_PATH", () => { test("matches the Runner's fixed in-container mount path", () => { expect(AGENT_SOCKET_PATH).toBe("/run/compass/agent.sock"); }); + + // The path became env-overridable for the host tier (design "Agent transport: + // the socket and config paths"): the host-process backend has no bind mounts, + // so it serves the socket inside the agent handle's own state dir and threads + // the path via COMPASS_AGENT_SOCKET_PATH. The frozen literal stays the DEFAULT + // — a container-tier agent (no override) resolves it unchanged. + test("resolveSocketPath defaults to the frozen path when unset or blank", () => { + expect(resolveSocketPath({})).toBe(AGENT_SOCKET_PATH); + expect(resolveSocketPath({ COMPASS_AGENT_SOCKET_PATH: " " })).toBe( + AGENT_SOCKET_PATH, + ); + }); + + test("resolveSocketPath returns the COMPASS_AGENT_SOCKET_PATH override when set", () => { + expect( + resolveSocketPath({ + COMPASS_AGENT_SOCKET_PATH: "/state/agent-7/socket/agent.sock", + }), + ).toBe("/state/agent-7/socket/agent.sock"); + }); }); // COMPASS_MODEL is the Matt-ruled runtime seam for model selection. The @@ -1012,6 +1033,32 @@ describe("main", () => { expect(dialed).toEqual([AGENT_SOCKET_PATH]); }); + // The host tier serves the socket inside the agent handle's own state dir and + // threads its path via COMPASS_AGENT_SOCKET_PATH — so main must dial the + // OVERRIDE, not the frozen constant. Pinning it AT THE CALL SITE catches a + // main that resolved the env but still dialed the default. Non-vacuity: + // reverting cli.ts to dial AGENT_SOCKET_PATH reds this while the default-dial + // test above stays green. + test("dials the carrier at the COMPASS_AGENT_SOCKET_PATH override when set", async () => { + const dialed: string[] = []; + const session = fakeSession(); + await main( + { + HOME: scratch(), + COMPASS_AGENT_SOCKET_PATH: "/state/agent-7/socket/agent.sock", + }, + { + createSession: () => + Promise.resolve({ session: session as unknown as AgentSession }), + createTransport: (socketPath) => { + dialed.push(socketPath); + return fakeCarrier(emptyLog(), { control: emptyControlStream }); + }, + }, + ); + expect(dialed).toEqual(["/state/agent-7/socket/agent.sock"]); + }); + // COMPASS_MODEL / COMPASS_WORKDIR are the container's only two configuration // knobs, and `main` is the sole place they become session options. The // resolution rules are unit-tested above; this pins that main actually FORWARDS @@ -2654,6 +2701,40 @@ function toolNames(tools: unknown[] | undefined): string[] { } describe("main wires the mounted agent-config into createAgentSession", () => { + // The host tier materializes config inside the agent handle's state dir and + // threads the root via COMPASS_AGENT_CONFIG_MOUNT_PATH — NOT the deps.configMount + // test seam. This drives the mount through the ENV VAR alone (deps.configMount + // unset) and asserts a mounted skill reaches options.skills, proving main + // resolves the override end-to-end. Non-vacuity: reverting cli.ts to read + // AGENT_CONFIG_MOUNT_PATH reds this (the default path does not exist, so no + // skill loads) while the frozen-default main tests below stay green. + test("reads the mount at the COMPASS_AGENT_CONFIG_MOUNT_PATH override when deps.configMount is unset", async () => { + const mount = scratch(); + writeMount(mount, "skills/host-skill/SKILL.md", mountSkill("host-skill")); + const session = fakeSession(); + const seen: SeenConfig[] = []; + await main( + { HOME: scratch(), COMPASS_AGENT_CONFIG_MOUNT_PATH: mount }, + { + connectMcp: () => + Promise.resolve({ + tools: [] as never, + disconnect: () => Promise.resolve(), + }), + createSession: (options) => { + seen.push({ skills: options.skills }); + return Promise.resolve({ + session: session as unknown as AgentSession, + }); + }, + createTransport: () => + fakeCarrier(emptyLog(), { control: emptyControlStream }), + }, + ); + expect(seen).toHaveLength(1); + expect(skillNames(seen[0].skills)).toEqual(["host-skill"]); + }); + test("a populated mount → skills, extension paths, and MCP tools all reach the options", async () => { const mount = scratch(); writeMount(mount, "skills/alpha/SKILL.md", mountSkill("alpha")); diff --git a/packages/compass-agent/src/cli.ts b/packages/compass-agent/src/cli.ts index c943ca56e..2ecf9d927 100644 --- a/packages/compass-agent/src/cli.ts +++ b/packages/compass-agent/src/cli.ts @@ -61,11 +61,11 @@ import { CompassAgent } from "./agent"; import { BoardBroker, createBoardTools } from "./board"; import { CommsBroker, createCommsTools } from "./comms"; import { - AGENT_CONFIG_MOUNT_PATH, currentConfigDir, loadMountedConfig, type MountedMcp, readMountedRolePrompt, + resolveConfigMountPath, } from "./config-reader"; import { createForgeTools, ForgeBroker } from "./forge"; import type { FrameSink } from "./frame"; @@ -90,6 +90,24 @@ import { */ export const AGENT_SOCKET_PATH = "/run/compass/agent.sock"; +/** + * The gateway-socket path this agent dials: the `COMPASS_AGENT_SOCKET_PATH` + * env override when set, else the frozen `AGENT_SOCKET_PATH` default. + * + * The container tiers bind-mount the socket at the fixed default and set no + * override, so they resolve `AGENT_SOCKET_PATH` unchanged. The host-process tier + * has no bind mounts — it serves the socket inside the agent handle's own state + * dir and threads the path here (design "Agent transport: the socket and config + * paths"). Unset or blank is the default, matching the Runner's empty-omit of an + * unset var (`go/internal/runner/agent_exec.go` execSpec) and every other + * `resolve*` here: a blank override is not a valid socket to dial. + */ +export function resolveSocketPath( + env: Record, +): string { + return env.COMPASS_AGENT_SOCKET_PATH?.trim() || AGENT_SOCKET_PATH; +} + /** The 0600 provider-credential seed the Runner materializes (design §T5). */ export function authSeedPath(home: string): string { return `${home}/.compass/auth-seed.json`; @@ -675,7 +693,7 @@ export async function main( // committed session write onto the sink's DURABLE lane (RIG-1570), so the // sink must exist before the storage that holds it. const transport = (deps.createTransport ?? createUnixSocketTransport)( - AGENT_SOCKET_PATH, + resolveSocketPath(env), ); const sink = createSocketFrameSink(transport); @@ -761,9 +779,11 @@ export async function main( // session constructs with NONE injected. process.env is already sourced // (above), so a connected MCP server inherits its credentials (credential- // free configs by MVP rule; the reader resolves none). - const mounted = await loadMountedConfig( - deps.configMount ?? AGENT_CONFIG_MOUNT_PATH, - ); + // The test seam wins when set (a tempdir fixture); otherwise resolve the + // `COMPASS_AGENT_CONFIG_MOUNT_PATH` env override, defaulting to the frozen + // mount path — the host tier supplies the override, the container tiers do not. + const configMount = deps.configMount ?? resolveConfigMountPath(env); + const mounted = await loadMountedConfig(configMount); // The bundle hash, for one observability line. Non load-bearing: absent → no // line, and nothing gates on it. if (mounted.version) { @@ -779,10 +799,7 @@ export async function main( // symlink the Runner flips, so a ConfigVersion flip stays live. Persona still // appends AFTER this block (record §OQ-8) — see the createSession call. const rolePrompt = role - ? await readMountedRolePrompt( - currentConfigDir(deps.configMount ?? AGENT_CONFIG_MOUNT_PATH), - role, - ) + ? await readMountedRolePrompt(currentConfigDir(configMount), role) : undefined; if (role && rolePrompt === undefined) { // A role was selected but its prompt did not materialize (absent, empty, or diff --git a/packages/compass-agent/src/config-reader.test.ts b/packages/compass-agent/src/config-reader.test.ts index 6834fdbc8..c4f986f5e 100644 --- a/packages/compass-agent/src/config-reader.test.ts +++ b/packages/compass-agent/src/config-reader.test.ts @@ -26,6 +26,7 @@ import { readMountedRules, readMountedSettingsPath, readMountedSkills, + resolveConfigMountPath, } from "./config-reader"; const tmpdirs: string[] = []; @@ -76,6 +77,27 @@ describe("AGENT_CONFIG_MOUNT_PATH", () => { "/run/compass/agent-config/current", ); }); + + // The mount path became env-overridable for the host tier (design "Agent + // transport: the socket and config paths"): the host-process backend has no + // bind mounts, so it materializes the config tree inside the agent handle's + // own state dir and threads the root via COMPASS_AGENT_CONFIG_MOUNT_PATH. The + // frozen literal stays the DEFAULT — a container-tier agent (no override) + // reads it unchanged. + test("resolveConfigMountPath defaults to the frozen path when unset or blank", () => { + expect(resolveConfigMountPath({})).toBe(AGENT_CONFIG_MOUNT_PATH); + expect( + resolveConfigMountPath({ COMPASS_AGENT_CONFIG_MOUNT_PATH: " " }), + ).toBe(AGENT_CONFIG_MOUNT_PATH); + }); + + test("resolveConfigMountPath returns the COMPASS_AGENT_CONFIG_MOUNT_PATH override when set", () => { + expect( + resolveConfigMountPath({ + COMPASS_AGENT_CONFIG_MOUNT_PATH: "/state/agent-7/config", + }), + ).toBe("/state/agent-7/config"); + }); }); // skills: providing the array to createAgentSession SKIPS discovery, so this diff --git a/packages/compass-agent/src/config-reader.ts b/packages/compass-agent/src/config-reader.ts index 9210b6973..48e82251f 100644 --- a/packages/compass-agent/src/config-reader.ts +++ b/packages/compass-agent/src/config-reader.ts @@ -52,6 +52,24 @@ import type { */ export const AGENT_CONFIG_MOUNT_PATH = "/run/compass/agent-config"; +/** + * The agent-config mount root this agent reads through: the + * `COMPASS_AGENT_CONFIG_MOUNT_PATH` env override when set, else the frozen + * `AGENT_CONFIG_MOUNT_PATH` default. + * + * The container tiers bind-mount the bundle at the fixed default and set no + * override. The host-process tier has no bind mounts — it materializes the config + * tree inside the agent handle's own state dir and threads the root here (design + * "Agent transport: the socket and config paths"). Unset or blank is the default, + * matching the Runner's empty-omit of an unset var and every other `resolve*`: + * a blank override is not a valid root to read. + */ +export function resolveConfigMountPath( + env: Record, +): string { + return env.COMPASS_AGENT_CONFIG_MOUNT_PATH?.trim() || AGENT_CONFIG_MOUNT_PATH; +} + /** * The source tag stamped on skills loaded from the mount, in the * `provider:level` shape `loadSkillsFromDir` splits (skills.ts). Provenance