Make the vsock control plane mandatory for every containerized run

The vsock control plane is now the ONLY container control plane — on
for shared control containers AND per-session sandbox containers; the
nucleic.container.vsockControlPlane defaults key, its getter, and the
legacy gateway-TCP fallback are retired (the Settings toggle was
already removed).

- SessionController sets controlSocketHostPath on every containerized
  spec; per-session containers get their own socket under the app-owned
  runtime dir, served by their backend's per-backend server.
- ClaudeCodeBackend refuses a containerized run whose spec lacks a
  control socket (fail-loud, never a silent TCP fallback the guest
  can't reach); TCP survives only as the host runs' loopback listener,
  so no 0.0.0.0 bind — and no macOS local-network prompt — remains.
  shutdown() now stops the per-backend server so per-session control
  sockets are unlinked when the session ends.
- ContainerEngine probes each fresh clone that carries a control socket
  for node + control-bridge.js and fails the start with an actionable
  error, so a custom image without the bridge (base images must be
  nucleic-sandbox:v4+) no longer surfaces as the CLI's opaque
  "Available MCP tools: none".

Side effect (intended): Codex/Grok sessions in per-session sandbox
projects now exec inside their container — their isHostRun check keys
off the control socket, so they previously ran on the host despite the
sandbox setting.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-09 22:13:21 -07:00
co-authored by Claude Fable 5
parent 6ecef44a05
commit 8f5ba8b3c7
8 changed files with 109 additions and 76 deletions
+6 -3
View File
@@ -106,9 +106,12 @@ public struct ContainerSpec: Sendable, Equatable {
/// interceptor endpoint. When set, ``ContainerEngine`` relays it into the guest over vsock (the
/// framework's `UnixSocketConfiguration(.into)`), where it appears at ``controlSocketGuestPath``,
/// so the agent's MCP bridge and the git/gh/command shims reach the host with **no IP listener**
/// (hence no macOS incoming-connection / local-network prompts). `nil` no relay (the legacy
/// TCP-over-vmnet-gateway path). MUST be short: AF_UNIX `sun_path` caps at ~104 bytes, so it
/// lives under a short dir (e.g. `/tmp`), never the long Application-Support session path.
/// (hence no macOS incoming-connection / local-network prompts). The vsock control plane is
/// MANDATORY for session containers `SessionController` sets this on every session spec
/// (shared and per-session), and backends refuse a containerized run without it. `nil` only for
/// agent-created throwaway containers (`linux_container`), which run no control plane. MUST be
/// short: AF_UNIX `sun_path` caps at ~104 bytes, so it lives under a short dir, never the long
/// Application-Support session path.
public let controlSocketHostPath: String?
/// Fixed in-guest path the relayed control socket appears at; the in-container control bridge
@@ -274,6 +274,12 @@ public actor ClaudeCodeBackend: AgentBackend {
public func shutdown() async {
terminating = true
await approvals.cancelOutstanding(reason: "Session terminated")
// Release this backend's OWN approval server its loopback TCP listener (host runs) or
// its per-session control socket (per-session containers), which would otherwise linger
// on disk after the session ends. Never the registry's shared server (`runServer` on a
// control-container run): other sessions in that container are still using it, and a
// future run of this backend re-`start()`s idempotently either way.
await approvalServer.stop()
guard let handle else { return }
handle.closeStdin()
await handle.terminate()
@@ -327,26 +333,23 @@ public actor ClaudeCodeBackend: AgentBackend {
}
runServer = server
// 0b. Sandbox: if this run is containerized, bring the container up first so we know the
// host-gateway address the child must use to reach us.
// 0b. Sandbox: if this run is containerized, bring the container up first (its init
// also launches the in-guest control bridge the child reaches us through).
let sandbox = (run.container != nil) ? containerManager : nil
var mcpHost = "127.0.0.1"
if let sandbox, let cspec = run.container {
let (name, gateway) = try await sandbox.ensureRunning(cspec)
let (name, _) = try await sandbox.ensureRunning(cspec)
activeContainerName = name
mcpHost = gateway
}
// 1. Approval bridge: per-session bearer token handler. For a containerized
// child we bind on all interfaces (gated by the bearer token) so it can reach
// us over the VM gateway; otherwise loopback as before.
// 1. Approval bridge: per-session bearer token handler.
let token = UUID().uuidString
serverToken = token
// Transport: with a relayed control socket, serve ONLY on the unix socket no IP
// listener at all, so macOS raises no incoming-connection / local-network prompts. The
// agent + interceptor shims reach us via the in-guest loopback bridge (which forwards to
// the relayed socket), so the control endpoint is `127.0.0.1:<bridge port>` inside the
// VM. Otherwise the legacy path: bind the gateway (containerized) or loopback (host).
// Transport: a containerized child ALWAYS rides the vsock control plane we serve
// ONLY on the relayed unix socket, no IP listener at all, so macOS raises no
// incoming-connection / local-network prompts. The agent + interceptor shims reach us
// via the in-guest loopback bridge (which forwards to the relayed socket), so the
// control endpoint is `127.0.0.1:<bridge port>` inside the VM. A host (non-container)
// run keeps the loopback TCP listener; the gateway-TCP path for containers is retired.
let port: UInt16
let controlHost: String
if let controlSock = run.container?.controlSocketHostPath {
@@ -362,9 +365,15 @@ public actor ClaudeCodeBackend: AgentBackend {
"approval control socket \(controlSock) is not listening "
+ "(control endpoint unreachable)")
}
} else if run.container != nil {
// Every containerized spec carries a control socket (SessionController sets it
// unconditionally). A spec without one would silently fall back to a TCP endpoint
// the guest can't reach enforce the invariant loudly instead.
throw BackendError.spawnFailed(
"containerized run has no control socket — the vsock control plane is mandatory")
} else {
port = try await server.start(host: sandbox != nil ? "0.0.0.0" : "127.0.0.1")
controlHost = mcpHost
port = try await server.start(host: "127.0.0.1")
controlHost = "127.0.0.1"
// A bound listener always reports a non-zero ephemeral port; a 0 here means the
// approval server didn't actually bind, so the agent could never reach us and would
// silently stall on its first gated tool. Fail the run loudly instead.
@@ -628,11 +637,11 @@ public actor ClaudeCodeBackend: AgentBackend {
.merging(cspec.env) { _, new in new }
.merging(run.extraEnv) { _, new in new }
// Tell the in-container `git` shim where to report (the host approval server,
// reached over the VM gateway) and under which per-session bearer token. The
// env is inherited by every git subprocess the agent spawns.
// reached over the in-guest control bridge) and under which per-session bearer
// token. The env is inherited by every git subprocess the agent spawns.
if cspec.installGitInterceptor {
// Point the in-container git/gh/command interceptor at the host control endpoint
// (gateway+TCP, or the loopback control bridge on the vsock path). Shared helper
// (the loopback control bridge, forwarding to the relayed socket). Shared helper
// so every containerized backend wires the interceptor identically.
env.merge(
CommandInterceptor.hookEnv(
@@ -122,6 +122,21 @@ extension ContainerEngine {
_ = try? await runToCompletion(container, ["sh", "-c", script])
}
/// Preflight for the mandatory vsock control plane: the guest must be able to run the
/// in-container control bridge `node` on PATH plus the bridge script the init already tried
/// to launch at start. Both ship in sandbox images `v4`; a custom image lacking either would
/// leave the agent with no route to the host control endpoint, surfacing only as the CLI's
/// opaque "Available MCP tools: none". Run once per fresh clone; throws an actionable error.
func verifyControlBridge(_ spec: ContainerSpec, in container: LinuxContainer) async throws {
let probe = "command -v node >/dev/null 2>&1 && test -f \(Self.controlBridgeGuestPath)"
let exitCode = (try? await runToCompletion(container, ["sh", "-c", probe])) ?? -1
guard exitCode != 0 else { return }
throw ContainerError.startFailed(
"image \(spec.image) can't run the mandatory vsock control plane: it must provide "
+ "`node` and \(Self.controlBridgeGuestPath). Base custom images on "
+ "\(ProjectSandbox.defaultImage) (v4 or later).")
}
// MARK: - Disk GC (the daemonless replacement for orphan reaping)
/// On launch / after teardown: delete per-container rootfs clones whose name isn't in
@@ -600,6 +600,14 @@ public actor ContainerEngine {
if freshlyCloned {
await seed(spec, in: container)
// The vsock control plane is mandatory for agent containers, and its in-guest half
// `node` + the control bridge rides in the image (sandbox image v4). A custom
// image missing either would otherwise surface only as the agent CLI's opaque
// "Available MCP tools: none" once the agent can't reach the host; probe once per
// fresh clone and fail the start loudly and actionably instead.
if spec.controlSocketHostPath != nil {
try await verifyControlBridge(spec, in: container)
}
}
return (spec.name, gateway)
}
+8 -16
View File
@@ -53,8 +53,8 @@ public struct ProjectSandbox: Sendable, Codable, Equatable {
/// changes the new tag has no cache yet, so the next session pulls it fresh and launch-time
/// reconcile prunes the superseded cache. (`v2` added the cross-language build tools:
/// build-essential/make, python3/pip. `v3` added the GitHub CLI `gh` plus `curl`. `v4` added the
/// Codex + Grok CLIs and `control-bridge.js` the latter is REQUIRED by the now-default vsock
/// control plane, so `v4` must ship before `vsockControlPlaneEnabled` defaults on. `v5` added
/// Codex + Grok CLIs and `control-bridge.js` the latter is REQUIRED by the now-mandatory vsock
/// control plane, so every image from `v4` on must keep shipping it. `v5` added
/// `openssh-client` for `ssh-keygen`, so the agent can sign commits with `gpg.format=ssh`.)
/// Keep in lockstep with `.github/workflows/sandbox-image.yml`'s `IMAGE_TAG`.
public static let defaultImage = "ghcr.io/abkslm/nucleic-sandbox:v5"
@@ -473,20 +473,12 @@ public enum ContainerServiceSettings {
defaults.bool(forKey: splitControlContainersByBackendKey)
}
/// When on, a shared Nucleic Control container's control plane (the approval MCP server + the
/// git/gh/command interceptor endpoint) runs over a **vsock-relayed unix socket** instead of
/// TCP/HTTP on the VM gateway so macOS raises no incoming-connection / local-network prompts.
/// The agent and the interceptor shims reach the host through an in-container loopback bridge
/// that forwards to the relayed socket (see `docs/VSOCK_CONTROL_PLANE.md`). **On by default** as
/// of sandbox image `v4` (which ships `control-bridge.js`); set the key to `false` to fall back to
/// the legacy gateway-TCP path. Because it requires the bridge in the image, the default must move
/// in lockstep with `ProjectSandbox.defaultImage` being a bridge-bearing tag ( `v4`).
public static let vsockControlPlaneEnabledKey = "nucleic.container.vsockControlPlane"
/// **Always on.** The vsock control plane is mandatory there is no longer a user-facing toggle
/// to disable it, and any previously stored preference is ignored. The legacy gateway-TCP fallback
/// path has been retired now that every shipping sandbox image ( `v4`) carries `control-bridge.js`.
public static var vsockControlPlaneEnabled: Bool { true }
// The vsock control plane (the approval MCP server + interceptor endpoint over a
// vsock-relayed unix socket, `docs/VSOCK_CONTROL_PLANE.md`) is MANDATORY always on for
// every containerized run, shared and per-session alike. The old
// `nucleic.container.vsockControlPlane` toggle and the legacy gateway-TCP fallback are
// retired: every shipping sandbox image ( `v4`) carries `control-bridge.js`, and a custom
// image without it fails loudly at container start (the engine's fresh-clone preflight).
/// Whether the in-container **bash command tracer** is active. The tracer installs a `DEBUG`/
/// `EXIT` trap (sourced via `BASH_ENV`) that records EVERY command the agent runs inside a Bash
+9 -7
View File
@@ -677,13 +677,15 @@ public actor SessionController {
) { _, new in new }
}
// The shared control container can run its control plane over a vsock-relayed unix socket
// (no IP listener no macOS prompts) when enabled it's the long-lived, token-multiplexed
// box the per-container socket + in-guest bridge are designed for. Per-session containers
// stay on the gateway-TCP path. `nil` legacy TCP (the default).
let controlSocketHostPath: String? =
(shared && ContainerServiceSettings.vsockControlPlaneEnabled)
? ApprovalServerRegistry.controlSocketPath(for: name) : nil
// Control plane: EVERY containerized run rides the vsock-relayed unix socket (no IP
// listener no macOS local-network prompts). The vsock control plane is mandatory the
// legacy gateway-TCP path is retired, for per-session containers too. A shared control
// container meets the long-lived registry server at its container-stable path; a
// per-session container gets its own socket (same app-owned runtime dir, keyed by its
// unique name), served by its backend's own server. Requires the in-guest bridge
// sandbox image v4; a custom image must carry `node` + the bridge script, enforced by
// the engine's fresh-clone preflight so a bridge-less image fails loudly at start.
let controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)
return ContainerSpec(
name: name,
@@ -40,14 +40,16 @@ struct ContainerSandboxTests {
// MARK: - ContainerSpec control-socket relay plumbing (no VM required)
/// The optional control-socket relay defaults off, survives the `renamed` copy the engine
/// boundary makes for shared control containers, and exposes the agreed fixed guest mount point
/// the in-guest bridge/shims connect to.
/// The optional control-socket relay defaults off at the STRUCT level (agent-created throwaway
/// containers run no control plane; session specs always set it the vsock control plane is
/// mandatory), survives the `renamed` copy the engine boundary makes for shared control
/// containers, and exposes the agreed fixed guest mount point the in-guest bridge/shims
/// connect to.
@Test func controlSocketRelayPlumbing() {
let base = ContainerSpec(
name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
idleTimeout: 60, claudeHomeStaging: "/s", claudeHomeWritable: "/h")
#expect(base.controlSocketHostPath == nil) // default: no relay (legacy TCP path)
#expect(base.controlSocketHostPath == nil) // struct default: no relay (no control plane)
let wired = ContainerSpec(
name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
+30 -28
View File
@@ -21,7 +21,7 @@ Egress (the agent's outbound internet — Anthropic API, `git`, `npm`, `gh`) sta
| | Transport | Carries | Status |
|---|---|---|---|
| stdio | virtio-vsock | `stream-json` / NDJSON prompt | already vsock — untouched |
| control plane | unix socket relayed over vsock (via in-guest loopback bridge) | approvals (MCP), git/gh/command interceptor events | **default** as of sandbox image `v4`; legacy gateway-TCP kept behind the off switch |
| control plane | unix socket relayed over vsock (via in-guest loopback bridge) | approvals (MCP), git/gh/command interceptor events | **mandatory** (2026-07-09) for every containerized run — shared AND per-session; the toggle and the legacy gateway-TCP path are retired |
| egress | vmnet NAT (gateway) | agent's outbound internet — Anthropic API, `git`, `npm`, `gh` + DNS | stays on NAT by design (§7) — can't ride vsock |
## Current state of Channel 2 (before this work)
@@ -133,21 +133,24 @@ init child, so it reaches the **root-owned** relayed socket, while the non-root
interceptor shims only ever touch **loopback TCP** — no socket-permission juggling, and (crucially)
**the git/gh/command shims need no change**: they keep POSTing HTTP, now to the loopback bridge.
Host side (`ClaudeCodeBackend`): when the run's container carries a `controlSocketHostPath`, the
(shared) server is started on the **UDS only — no IP listener at all** — and the control endpoint
handed to the agent + shims becomes `127.0.0.1:<bridge port>` (`mcpConfig` + the `NUCLEIC_*_HOOK_URL`
env). Otherwise the legacy gateway-TCP path is used unchanged.
Host side (`ClaudeCodeBackend`): the run's container always carries a `controlSocketHostPath`, so the
server is started on the **UDS only — no IP listener at all** — and the control endpoint handed to
the agent + shims becomes `127.0.0.1:<bridge port>` (`mcpConfig` + the `NUCLEIC_*_HOOK_URL` env). A
containerized spec without a control socket fails the run loudly (the invariant is enforced, not
silently degraded).
### 4. Wiring + the gate — DONE; default ON as of image `v4`
### 4. Wiring — DONE; MANDATORY as of 2026-07-09 (was: gated, then default-on with image `v4`)
One flag gates the whole vertical: `ContainerServiceSettings.vsockControlPlaneEnabled` (now **default
on** — `true` when unset, an explicit stored `false` still wins; Settings → "Control plane over
vsock"). `SessionController.containerSpec()` sets
`controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)` only for a shared
control container when the flag is on; everything downstream keys off that one field (relay attach,
init bridge, UDS-only server, loopback endpoint). Flag off → byte-for-byte the legacy path. The
default was moved in lockstep with `ProjectSandbox.defaultImage` reaching a bridge-bearing tag (`v4`),
since the path requires `control-bridge.js` in the image.
The gate is gone: `ContainerServiceSettings.vsockControlPlaneEnabled` and its defaults key were
retired (the Settings toggle had already been removed). `SessionController.containerSpec()` sets
`controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)` for **every**
containerized spec — shared control containers AND per-session sandbox containers; everything
downstream keys off that one field (relay attach, init bridge, UDS-only server, loopback endpoint).
A shared control container meets the long-lived registry server; a per-session container's socket is
served by its backend's own per-backend server (stopped — and the socket unlinked — at backend
shutdown). Because the path requires `control-bridge.js` + `node` in the image (sandbox image ≥
`v4`), `ContainerEngine` probes for both once per fresh clone and fails the start with an actionable
error when a custom image lacks them.
### 5. Auth — unchanged
@@ -155,13 +158,14 @@ The per-session bearer token is unchanged: it still rides the HTTP `Authorizatio
loopback → bridge → UDS), validated host-side exactly as before, and still disambiguates sessions on
the shared per-container socket.
### 6. Remove the IP listener — DONE on the flag path
### 6. Remove the IP listener — DONE
On the flag-on (now default) path the server never binds TCP (`start(unixSocketPath:)` only), so there
is no control-plane `NWListener` and `mcpConfig` carries no gateway host/port — the macOS
incoming-connection / local-network prompts have nothing to fire on. The legacy TCP path is retained
behind an explicit `vsockControlPlane = false` for fallback; it can be deleted once the vsock path has
soaked on real hardware.
For every containerized run the server never binds TCP (`start(unixSocketPath:)` only), so there is
no control-plane `NWListener` and `mcpConfig` carries no gateway host/port — the macOS
incoming-connection / local-network prompts have nothing to fire on. The legacy gateway-TCP fallback
is deleted; `ClaudeCodeBackend` refuses a containerized run whose spec somehow lacks a control socket
(fail-loud, never silently degrade to an endpoint the guest can't reach). TCP survives only as the
host (non-container) runs' loopback listener.
### 6b. Grok & Codex control containers — PARTIAL (behind the flag)
@@ -171,7 +175,7 @@ The container infra is backend-agnostic (registry keyed by name, the `nucleic-co
gap. Done this pass:
- **`GrokACPBackend` / `CodexAppServerBackend`** gained a container-exec path: when a run carries a
vsock control socket (so: flag on + shared control container) and a `ContainerManager` is present,
vsock control socket (now: every containerized run) and a `ContainerManager` is present,
the agent execs **inside the shared control container** (stdio over vsock) instead of on the host —
i.e. each agent family runs isolated in its own box ("agenticide" separation). Gated on that one
signal, so default behavior is unchanged. Both backends' approvals are native over their own stdio
@@ -225,7 +229,7 @@ re-trigger them). **Only if** a Local Network prompt persists, tunnel egress ove
`HTTPS_PROXY` → vsock → host forward proxy) and drop the IP interface — larger effort (DNS, non-HTTP
protocols); do not start unless needed.
## Now the default (image `v4`)
## Now mandatory (2026-07-09; default since image `v4`)
Promoted to default once the bridge-bearing image existed:
@@ -235,12 +239,10 @@ Promoted to default once the bridge-bearing image existed:
2. `ProjectSandbox.defaultImage` bumped to `:v4`; the launch-time rootfs prune
(`reconcileDisk`/`pruneObsoleteRootfs`) drops the stale `v3` cache and pulls `v4` fresh — no manual
re-pull needed.
3. `vsockControlPlaneEnabled` defaults on, so **Settings → Container → "Control plane over vsock"** is
on. Turn it off (or `defaults write … nucleic.container.vsockControlPlane -bool NO`) to fall back to
the legacy gateway-TCP path — no rebuild needed.
The first Nucleic Control session on real hardware is the validation: run the checklist below. Once it
has soaked, the legacy TCP path (§6) can be deleted.
3. Then made **mandatory**: the Settings toggle, the `nucleic.container.vsockControlPlane` defaults
key, and the legacy gateway-TCP fallback are all retired. Every containerized run — the shared
control containers and per-session sandbox containers alike — rides the relayed socket; custom
images must provide `node` + `control-bridge.js` (enforced by the engine's fresh-clone preflight).
### Manual verification checklist (first real-hardware run)