Files
nucleic/Sources/NucleicCore/Container/ContainerEngine+Rootfs.swift
T
abkslmandClaude Fable 5 8f5ba8b3c7 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]>
2026-07-09 22:13:21 -07:00

376 lines
19 KiB
Swift

import Foundation
import Containerization
import ContainerizationOCI
/// Rootfs management, per-container clones, seeding, and on-disk GC.
///
/// The old runtime built the sandbox image on-device with `container build`. The framework has no
/// Dockerfile builder, and provisioning in a builder VM at runtime is slow and needs the agent's
/// package registries reachable — so instead the `nucleic-sandbox` image is built in CI and pushed
/// to a registry, and the engine just **pulls + unpacks** it to an ext4 (cached per image version),
/// then **clones** that per container so each gets an isolated writable rootfs. Users install no
/// build tools; the toolchain ships inside the published image.
extension ContainerEngine {
// MARK: - On-disk layout
private var rootfsDir: URL { storageRoot.appendingPathComponent("rootfs", isDirectory: true) }
private var instancesDir: URL { storageRoot.appendingPathComponent("instances", isDirectory: true) }
/// A filesystem-safe form of an image reference (e.g. `ghcr.io/abkslm/nucleic-sandbox:v3` →
/// `ghcr.io_abkslm_nucleic-sandbox_v3`), used as the cache filename stem.
static func sanitizeRef(_ ref: String) -> String {
String(ref.map { ($0 == "/" || $0 == ":") ? "_" : $0 })
}
/// Cache file for an unpacked image, keyed by a filesystem-safe form of the ref.
private func rootfsCacheURL(for imageRef: String) -> URL {
rootfsDir.appendingPathComponent(Self.sanitizeRef(imageRef) + ".ext4")
}
/// Per-container writable rootfs clone.
func instanceRootfsURL(for name: String) -> URL {
instancesDir.appendingPathComponent(name + ".ext4")
}
// MARK: - Per-container rootfs (clone of the cached image)
/// Produce the writable rootfs `Mount` for a container: ensure the image's rootfs is built and
/// cached, then return a per-container clone of it. `freshlyCloned` is false when an existing
/// clone is reused (idle-restart / relaunch — its instrumentation is already installed, so
/// seeding is skipped).
func instanceRootfs(for spec: ContainerSpec) async throws
-> (rootfs: Containerization.Mount, freshlyCloned: Bool)
{
try FileManager.default.createDirectory(at: instancesDir, withIntermediateDirectories: true)
let instanceURL = instanceRootfsURL(for: spec.name)
if FileManager.default.fileExists(atPath: instanceURL.path) {
return (Containerization.Mount.block(format: "ext4", source: instanceURL.path, destination: "/"), false)
}
let cacheURL = try await ensureCachedRootfs(imageRef: spec.image)
let base = Containerization.Mount.block(format: "ext4", source: cacheURL.path, destination: "/")
let cloned = try base.clone(to: instanceURL.path)
return (cloned, true)
}
// MARK: - Cached image rootfs
/// Ensure the rootfs for `imageRef` is pulled and cached, returning the cache file URL. Every
/// image — the default `nucleic-sandbox` (built in CI, pushed to a registry) and any custom
/// bring-your-own ref alike — is simply pulled and unpacked. There is no on-device provisioning:
/// the toolchain + Claude Code live in the published image, so users need no build tools.
private func ensureCachedRootfs(imageRef: String) async throws -> URL {
let cacheURL = rootfsCacheURL(for: imageRef)
if FileManager.default.fileExists(atPath: cacheURL.path) { return cacheURL }
try FileManager.default.createDirectory(at: rootfsDir, withIntermediateDirectories: true)
// The dominant first-run download — surface it to the Control panel + chat for the whole
// pull-then-unpack span (one begin/end, with the phase advanced from .image to .unpack).
beginDownload(.image)
defer { endDownload() }
// Unpack into a temp file (EXT4Unpacker refuses to overwrite an existing path), then publish
// atomically so a partial/interrupted pull never leaves a corrupt cache in place.
let tmpURL = rootfsDir.appendingPathComponent(".pull-\(UUID().uuidString).ext4")
try? FileManager.default.removeItem(at: tmpURL)
do {
let store = try sharedImageStore()
let image: Containerization.Image
do {
// Pull with registry credentials when configured (private GHCR), else anonymously.
// The progress handler streams layer byte totals into the panel's determinate bar.
image = try await store.pull(
reference: imageRef, platform: .current, auth: registryAuth(for: imageRef),
progress: { [self] events in await advanceDownload(events, phase: .image) })
} catch {
throw ContainerError.imagePullFailed("\(imageRef): \(error)")
}
// 8 GiB ext4: room for the image contents plus the agent's working files. Unpack reports
// no byte stream, so this stage shows as an indeterminate "Unpacking sandbox image" bar.
setDownloadPhase(.unpack)
let unpacker = EXT4Unpacker(blockSizeInBytes: 8 * 1024 * 1024 * 1024)
_ = try await unpacker.unpack(image, for: .current, at: tmpURL)
try? FileManager.default.removeItem(at: cacheURL)
try FileManager.default.moveItem(at: tmpURL, to: cacheURL)
return cacheURL
} catch {
try? FileManager.default.removeItem(at: tmpURL)
if let ce = error as? ContainerError { throw ce }
throw ContainerError.rootfsBuildFailed("\(imageRef): \(error)")
}
}
// MARK: - Seeding (per fresh container)
/// One-time per-container setup, run as root via `exec` (replaces the old `container exec sh -c`
/// seed). Marks the bind-mounted repos as a git "safe directory" (the agent runs as the host
/// uid against root-owned mounts, which trips git's dubious-ownership guard), gives the agent's
/// uid a passwd entry, and — for Nucleic Control containers — installs the `git`/`gh` interceptor
/// shims and the command interceptor ahead of the real binaries on PATH. Best-effort.
func seed(_ spec: ContainerSpec, in container: LinuxContainer) async {
var script = "git config --system --add safe.directory '*' 2>/dev/null || true"
if let uid = spec.runAsUID {
let gid = spec.runAsGID ?? 0
script += "; getent passwd \(uid) >/dev/null 2>&1"
+ " || echo 'nucleic:x:\(uid):\(gid):Nucleic Agent:/tmp:/bin/bash' >> /etc/passwd 2>/dev/null || true"
}
if spec.installGitInterceptor {
script += "; mkdir -p /usr/local/bin"
script += installShimSnippet(Self.gitInterceptorSource, at: "/usr/local/bin/git")
script += installShimSnippet(Self.ghInterceptorSource, at: "/usr/local/bin/gh")
script += "; " + CommandInterceptor.installScript()
}
_ = 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
/// `keepNames` (dead per-session containers, plus the shared control containers — which are
/// always rebuilt fresh), then prune superseded image caches. There are no live VMs to reap;
/// the registry is empty in a fresh process.
///
/// GC is scoped to THIS build channel: several installed builds (release + beta + local dev)
/// share this store, each owning only its `ContainerManager.channelSuffix`-tagged clones, so a
/// beta launch must not reap a release's per-session clones (and vice-versa).
public func reconcileDisk(keepNames: Set<String>) async {
if let clones = try? FileManager.default.contentsOfDirectory(
at: instancesDir, includingPropertiesForKeys: nil)
{
for clone in clones where clone.pathExtension == "ext4" {
let name = clone.deletingPathExtension().lastPathComponent
guard ContainerManager.ownsContainer(named: name) else { continue } // not our channel
if !keepNames.contains(name) {
try? FileManager.default.removeItem(at: clone)
}
}
}
await pruneObsoleteRootfs()
}
/// Force a re-pull of the default image: delete its cached rootfs so the next session pulls it
/// fresh. Mirrors the old `removeDefaultImage` used by "Force re-creation".
public func removeDefaultRootfs() async {
try? FileManager.default.removeItem(at: rootfsCacheURL(for: ProjectSandbox.defaultImage))
}
/// Remove cached rootfs files for superseded versions of the default image — the leftovers after
/// the default image tag is bumped (e.g. an old `…nucleic-sandbox_v2` after moving to `v3`).
/// Matches by the default ref's family (everything before its tag), so a custom bring-your-own
/// cache is never touched. Best-effort.
public func pruneObsoleteRootfs() async {
let defaultRef = ProjectSandbox.defaultImage
// The family is the ref without its tag; a ':' that sits after the last '/' is a tag
// separator (not a registry host:port), so strip from there.
let familyRef: String = {
guard let colon = defaultRef.lastIndex(of: ":") else { return defaultRef }
if let slash = defaultRef.lastIndex(of: "/"), slash > colon { return defaultRef }
return String(defaultRef[..<colon])
}()
let familyPrefix = Self.sanitizeRef(familyRef) + "_" // the sanitized tag separator
let current = rootfsCacheURL(for: defaultRef).lastPathComponent
guard let files = try? FileManager.default.contentsOfDirectory(
at: rootfsDir, includingPropertiesForKeys: nil) else { return }
for file in files where file.pathExtension == "ext4" {
let base = file.lastPathComponent
if base.hasPrefix(familyPrefix) && base != current {
try? FileManager.default.removeItem(at: file)
}
}
}
// MARK: - Exec helper
/// Run a one-shot command to completion in a container (no stdio piped) and return its exit
/// code. Used for seeding and provisioning.
private func runToCompletion(_ container: LinuxContainer, _ argv: [String], uid: UInt32 = 0)
async throws -> Int32
{
let id = "admin-\(UUID().uuidString.prefix(8))"
let process = try await container.exec(id) { p in
p.arguments = argv
p.user = ContainerizationOCI.User(uid: uid, gid: 0)
p.terminal = false
}
try await process.start()
return try await process.wait().exitCode
}
/// A `&& …` shell fragment that decodes a base64'd shim source to `path` and makes it
/// executable. Centralized so the `git` and `gh` shims install identically.
private func installShimSnippet(_ source: String, at path: String) -> String {
let b64 = Data(source.utf8).base64EncodedString()
return " && printf %s \(shellQuote(b64)) | base64 -d > \(path)"
+ " && chmod 0755 \(path)"
}
private func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
// The sandbox image's toolchain (git, gh, build-essential, python3, Claude Code, …) is now
// baked into the published `nucleic-sandbox` image at build time — see
// containers/nucleic-sandbox/Dockerfile and the CI workflow that pushes it. Nothing is
// provisioned on-device; the engine only pulls + unpacks.
// MARK: - In-guest interceptor shim sources
/// Source of the `git` interceptor shim installed at `/usr/local/bin/git` (which precedes
/// `/usr/bin` on the container's default PATH) in Nucleic Control containers. It runs the
/// real git transparently (stdio inherited, exit status preserved), then — for mutating
/// ops — reports the invocation to the Nucleic host via the per-session callback URL/token
/// injected into the exec env (`NUCLEIC_GIT_HOOK_URL`, `NUCLEIC_GIT_HOOK_TOKEN`,
/// `NUCLEIC_SESSION_ID`). Implemented in Node (always present in the image) so JSON
/// encoding and the HTTP POST are robust; reporting is strictly best-effort.
nonisolated static let gitInterceptorSource = #"""
#!/usr/bin/env node
'use strict';
// Nucleic git interceptor. See ContainerEngine.gitInterceptorSource.
const { spawnSync } = require('child_process');
const http = require('http');
const REAL_GIT = process.env.NUCLEIC_REAL_GIT || '/usr/bin/git';
const args = process.argv.slice(2);
const result = spawnSync(REAL_GIT, args, { stdio: 'inherit' });
const code = (result.status != null) ? result.status : (result.signal ? 1 : 0);
let done = false;
function finish() { if (done) return; done = true; process.exit(code); }
function firstSubcommand(a) {
for (let i = 0; i < a.length; i++) {
const t = a[i];
if (t === '-C' || t === '-c') { i++; continue; }
if (t.charAt(0) === '-') continue;
return t;
}
return null;
}
// Report only mutating / state-changing ops; reads are skipped to avoid spamming the
// host. The host re-classifies the argv authoritatively.
const REPORTABLE = new Set([
'merge','commit','rebase','cherry-pick','revert','reset','push','pull','fetch','clone',
'switch','checkout','branch','tag','stash','init','am','restore','mv','rm','apply','format-patch',
]);
const url = process.env.NUCLEIC_GIT_HOOK_URL;
const token = process.env.NUCLEIC_GIT_HOOK_TOKEN;
const sub = firstSubcommand(args);
let target = null;
if (url) { try { target = new URL(url); } catch (e) { target = null; } }
if (!token || !target || !sub || !REPORTABLE.has(sub)) {
finish();
} else {
const body = JSON.stringify({
argv: args, cwd: process.cwd(), exitCode: code,
subcommand: sub, sessionId: process.env.NUCLEIC_SESSION_ID || null,
});
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname || '/git-event',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
'Content-Length': Buffer.byteLength(body),
},
timeout: 1500,
}, function (res) { res.resume(); res.on('end', finish); res.on('error', finish); });
req.on('error', finish);
req.on('timeout', function () { req.destroy(); finish(); });
req.end(body);
}
"""#
/// Source of the `gh` interceptor shim installed at `/usr/local/bin/gh` (ahead of `/usr/bin/gh`
/// on PATH) in Nucleic Control containers. The structural twin of `gitInterceptorSource`: it
/// runs real `gh` transparently (stdio inherited, exit status preserved), then — for the
/// agentic-action nouns (`pr`, `issue`, `release`, `repo`, `api`, …) — reports the invocation to
/// the host via the per-session callback (`NUCLEIC_GH_HOOK_URL`, `NUCLEIC_GH_HOOK_TOKEN`,
/// `NUCLEIC_SESSION_ID`). The host re-classifies argv authoritatively (`GhCommandSummary`),
/// dropping reads; reporting is strictly best-effort and never blocks or alters the gh call.
/// If real `gh` is missing it exits 127 rather than masking the failure with the shim's own 0.
nonisolated static let ghInterceptorSource = #"""
#!/usr/bin/env node
'use strict';
// Nucleic gh interceptor. See ContainerEngine.ghInterceptorSource.
const { spawnSync } = require('child_process');
const http = require('http');
const REAL_GH = process.env.NUCLEIC_REAL_GH || '/usr/bin/gh';
const args = process.argv.slice(2);
const result = spawnSync(REAL_GH, args, { stdio: 'inherit' });
// A spawn error (e.g. real gh absent) must not be reported as success.
const code = (result.status != null) ? result.status : (result.error ? 127 : (result.signal ? 1 : 0));
let done = false;
function finish() { if (done) return; done = true; process.exit(code); }
// The first non-flag token is gh's noun (`pr`, `issue`, …); gh has no value-taking global
// flags before it. The host decides verb-level mutating-vs-read.
function firstNoun(a) {
for (let i = 0; i < a.length; i++) {
if (a[i].charAt(0) === '-') continue;
return a[i];
}
return null;
}
// Report only the nouns that can carry an agentic action; the host (GhCommandSummary)
// drops the read verbs (list/view/status) within them.
const REPORTABLE = new Set([
'pr','issue','release','repo','gist','workflow','run','secret','variable','label',
'cache','ssh-key','gpg-key','api',
]);
const url = process.env.NUCLEIC_GH_HOOK_URL;
const token = process.env.NUCLEIC_GH_HOOK_TOKEN;
const noun = firstNoun(args);
let target = null;
if (url) { try { target = new URL(url); } catch (e) { target = null; } }
if (!token || !target || !noun || !REPORTABLE.has(noun)) {
finish();
} else {
const body = JSON.stringify({
argv: args, cwd: process.cwd(), exitCode: code,
subcommand: noun, sessionId: process.env.NUCLEIC_SESSION_ID || null,
});
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname || '/gh-event',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
'Content-Length': Buffer.byteLength(body),
},
timeout: 1500,
}, function (res) { res.resume(); res.on('end', finish); res.on('error', finish); });
req.on('error', finish);
req.on('timeout', function () { req.destroy(); finish(); });
req.end(body);
}
"""#
}