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]>
376 lines
19 KiB
Swift
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);
|
|
}
|
|
"""#
|
|
}
|