From d7236b1f9773d09a11bfe6a84a75d8c5d33c749b Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 21:43:44 -0400 Subject: [PATCH 1/4] feat(runner): deliver the agent socket and config by path on the host backend (RIG-3512) A host process has no bind mounts, so the leg that hands the agent its gateway socket and config tree cannot work the way the container tiers do. The host leg serves the socket and materializes config inside the handle's own private state dir, appends no mounts, and threads both paths to the agent as environment variables on the exec that starts it. Backend selection is an interface probe on the engine, matching the vsock leg, so every backend without the capability keeps its existing path byte-identically. The two agent-side paths become overridable, still defaulting to the frozen literals when unset. A blank value reads as unset, matching every other resolver in the agent, because the Runner omits an unset field entirely and an empty path is not something the agent can dial or read. The tests that pin the defaults keep pinning them and each gain an override case. Co-authored-by: Matt Wilkinson --- go/internal/runner/agent_exec.go | 15 + go/internal/runner/host.go | 140 ++++++++- go/internal/runner/host_gateway_test.go | 274 ++++++++++++++++++ go/internal/runtime/host_backend.go | 17 ++ packages/compass-agent/src/cli.test.ts | 81 ++++++ packages/compass-agent/src/cli.ts | 35 ++- .../compass-agent/src/config-reader.test.ts | 22 ++ packages/compass-agent/src/config-reader.ts | 18 ++ 8 files changed, 591 insertions(+), 11 deletions(-) create mode 100644 go/internal/runner/host_gateway_test.go 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/host.go b/go/internal/runner/host.go index 37e19c507..d6f0caf29 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,67 @@ 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 and the config tree in + // its config subdir — both created private by Create. Record the 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 +1073,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 +1081,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 +1103,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 +1124,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 +1158,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..f418a26f9 --- /dev/null +++ b/go/internal/runner/host_gateway_test.go @@ -0,0 +1,274 @@ +//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/ and config/ subdirs the host backend's Create makes), 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 (creating only the socket subdir), 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 + } + } else if err := os.MkdirAll(filepath.Join(dir, "config"), 0o700); 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/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 From e1bf3b91956cb7d9aaea4f8f7deae61c7468b8c6 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 22:13:16 -0400 Subject: [PATCH 2/4] test(runner): assert the host backend satisfies the workspace-uid capability (RIG-3512) The binding is structural and crosses packages, so a signature drift on either side would leave the host backend quietly not implementing it. The resolver would then hand back the baked constant -- the regression the capability exists to prevent -- and it would surface late, at the first provision, and not at all on a box whose euid is already 1000. Mirrors the assertions the preflight probes already carry. Co-authored-by: Matt Wilkinson --- go/internal/runner/spec.go | 6 ++++++ 1 file changed, 6 insertions(+) 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. From 02bd9938b926c41ac5a518010121f256d8f1f303 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 22:51:34 -0400 Subject: [PATCH 3/4] fix(runner): correct the host state-dir comment and the shared socket diagnostic Review round 1 on the host transport leg found two inaccuracies, both in text rather than behaviour. The state-dir comment claimed Create mints the config subdir private. It does not: Create makes the workspace, home and socket subdirs at 0700, and the materializer creates the config root itself at 0755 so the agent can traverse it. The 0700 state dir above it is what keeps it private. The test fake pre-created the subdir and so reinforced the claim; it now matches the real backend. The AF_UNIX budget message named --runtime-dir. That flag governs where the container tiers put the socket, but the host tier roots it under the backend's state root, so half the operators reading the message would go change a value with no effect on their path. It now names the socket's parent. The test pinned the old flag and now pins the remedy, and the runnerhub stand-in that mirrors the real string is updated with it. Co-authored-by: Matt Wilkinson --- go/internal/runner/gateway/socket.go | 9 ++++++--- go/internal/runner/gateway/socket_test.go | 12 +++++++----- go/internal/runner/host.go | 11 ++++++----- go/internal/runner/host_gateway_test.go | 2 -- go/internal/runnerhub/relay_operator_fault_test.go | 2 +- 5 files changed, 20 insertions(+), 16 deletions(-) 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 d6f0caf29..42478df3d 100644 --- a/go/internal/runner/host.go +++ b/go/internal/runner/host.go @@ -897,11 +897,12 @@ func (h *agentHost) provisionHostGateway(ctx context.Context, spec runtime.Agent 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 and the config tree in - // its config subdir — both created private by Create. Record the 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. + // 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"), diff --git a/go/internal/runner/host_gateway_test.go b/go/internal/runner/host_gateway_test.go index f418a26f9..f18421df7 100644 --- a/go/internal/runner/host_gateway_test.go +++ b/go/internal/runner/host_gateway_test.go @@ -71,8 +71,6 @@ func (r *hostStateFakeRuntime) Create(_ context.Context, spec runtime.WorkloadSp if err := os.WriteFile(filepath.Join(dir, "config"), []byte("x"), 0o600); err != nil { return "", err } - } else if err := os.MkdirAll(filepath.Join(dir, "config"), 0o700); err != nil { - return "", err } r.mu.Lock() r.calls = append(r.calls, "create") 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{ From 9bc5d69569665efd045b6b2438a75a0e1cc77444 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 23:27:53 -0400 Subject: [PATCH 4/4] docs(runner): stop the host fake's comments claiming Create mints the config root Round 2 caught two comment copies of the claim round 1 corrected in production: the fake's Create makes only the socket subdir, and the config root is the materializer's job, as it is on the real backend. The code was already right; only the comments still said otherwise. Co-authored-by: Matt Wilkinson --- go/internal/runner/host_gateway_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/go/internal/runner/host_gateway_test.go b/go/internal/runner/host_gateway_test.go index f18421df7..5dd66858a 100644 --- a/go/internal/runner/host_gateway_test.go +++ b/go/internal/runner/host_gateway_test.go @@ -28,9 +28,10 @@ import ( // 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/ and config/ subdirs the host backend's Create makes), keyed by the -// synthetic id it returns — so AgentStateDir(handle.ID()) resolves the same dir +// 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 { @@ -43,7 +44,7 @@ type hostStateFakeRuntime struct { // "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 (creating only the socket subdir), so the socket serves + // 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