Sandbox containers: per-channel name suffix so installed builds coexist

Append a build-channel tag (-beta / -rc / -local; none for stable) to every
container name so release + beta + local-dev builds running against the shared
on-disk container store don't collide on per-container rootfs clones. Routes the
NUCLEIC_CHANNEL define into NucleicCore (ContainerManager.channelSuffix) and
scopes launch-time disk GC to the current channel (ownsContainer) so one build
never reaps another's clones.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nucleic
2026-06-21 19:55:31 -07:00
co-authored by Claude Opus 4.8
parent 2f9252e430
commit f42131a516
4 changed files with 98 additions and 17 deletions
+9 -3
View File
@@ -7,8 +7,9 @@ import PackageDescription
// the app executable's PRODUCT name which becomes the on-disk binary and
// therefore the OS process name (Activity Monitor / `ps`):
// dev -> nucleic-local beta -> nucleic-beta rc -> nucleic-rc stable -> nucleic
// a compile-time define the app reads (`BuildChannel`) to pick its build identity
// and the warning banner (red local / blue beta / gold release-candidate / none stable).
// a compile-time define (`NUCLEIC_STABLE/RC/BETA/DEV`) read by NucleicApp for its build
// identity + warning banner (red local / blue beta / gold release-candidate / none stable),
// and by NucleicCore's `ContainerManager` to suffix non-release container names.
// Branch mapping (see BUILD.md): dev builds from `dev`, beta from `staging`,
// rc from `rc`, stable from `main`.
let nucleicChannel = (Context.environment["NUCLEIC_CHANNEL"] ?? "dev").lowercased()
@@ -68,7 +69,12 @@ let package = Package(
// the `User`/`Platform` types used directly by `ContainerEngine`.
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
]),
],
// The build channel reaches the container-naming code here: `ContainerManager` appends a
// per-channel suffix (e.g. `-beta`, `-local`) to non-release container names so a beta /
// local-dev build's containers don't collide with a release's on the shared on-disk
// container store (ContainerEngine.defaultStorageRoot). Same define NucleicApp's banner uses.
swiftSettings: [.define(channelDefine)]),
.executableTarget(
name: "NucleicApp",
dependencies: ["NucleicCore", .product(name: "SwiftTerm", package: "SwiftTerm")],
@@ -119,12 +119,17 @@ extension ContainerEngine {
/// `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)
}
@@ -21,11 +21,45 @@ public actor ContainerManager {
self.engine = engine
}
/// Per-channel suffix appended to every container name on non-release builds, so several
/// installed Nucleic builds (release + beta + local dev, etc.) running against the **shared**
/// on-disk container store (`ContainerEngine.defaultStorageRoot`) don't collide on their
/// per-container rootfs clones. Empty for the stable release; `-beta` / `-rc` / `-local` for the
/// non-release channels. Baked in at build time from the `NUCLEIC_CHANNEL` define (mirrors
/// NucleicApp's `BuildChannel`); NucleicCore is compiled with that define see Package.swift.
public nonisolated static let channelSuffix: String = {
#if NUCLEIC_STABLE
return ""
#elseif NUCLEIC_RC
return "-rc"
#elseif NUCLEIC_BETA
return "-beta"
#else
return "-local"
#endif
}()
/// Every non-empty `channelSuffix` a build can carry. Used to tell a stable build's untagged
/// containers apart from other channels' tagged ones during disk GC. Keep in lockstep with the
/// `channelSuffix` cases above.
public nonisolated static let nonReleaseSuffixes = ["-local", "-beta", "-rc"]
/// Whether a container/clone name belongs to THIS build channel's namespace. The on-disk
/// container store is shared across installed builds (`ContainerEngine.defaultStorageRoot`), so
/// the launch-time disk GC uses this to reap only its own channel's clones and never another
/// build's. A non-release build owns names ending in its `channelSuffix`; the stable release owns
/// names carrying none of the non-release tags.
public nonisolated static func ownsContainer(named name: String) -> Bool {
channelSuffix.isEmpty
? !nonReleaseSuffixes.contains(where: name.hasSuffix)
: name.hasSuffix(channelSuffix)
}
/// Stable container name for a session. Lowercased because `UUID().uuidString` (hence
/// `SessionID.short`) is uppercase, while container names are conventionally lowercase
/// and our reconcile parser matches lowercase hex.
/// and our reconcile parser matches lowercase hex. Carries the per-channel `channelSuffix`.
public nonisolated static func containerName(for session: SessionID) -> String {
"nucleic-\(session.short.lowercased())"
"nucleic-\(session.short.lowercased())\(channelSuffix)"
}
/// The single **primary** container shared by all Nucleic Control projects that haven't
@@ -33,14 +67,15 @@ public actor ContainerManager {
/// the reconcile/list parser never mistakes it for a per-session container (and so a
/// per-session `teardown` which computes a `nucleic-<short>` name never removes it).
/// Its busy/idle lifecycle is ref-counted by name across every control session that uses it.
/// Also the home of Claude sessions when backend-splitting is on.
public static let sharedControlContainerName = "nucleic-control"
/// Also the home of Claude sessions when backend-splitting is on. Carries `channelSuffix`.
public static let sharedControlContainerName = "nucleic-control" + channelSuffix
/// The separate shared control container for GPT/Codex sessions, used only when
/// `ContainerServiceSettings.splitControlContainersByBackend` is on keeping competitive
/// Claude and GPT agents out of one sandbox where they kill each other's processes. Same
/// non-`nucleic-<8 hex>` shape, so it's likewise invisible to the per-session list parser.
public static let codexControlContainerName = "nucleic-control-codex"
/// The `channelSuffix` lands at the very end (e.g. `nucleic-control-codex-beta`).
public static let codexControlContainerName = "nucleic-control-codex" + channelSuffix
/// Every shared control container name that can exist. Lifecycle sweeps (reconcile, teardown,
/// status, recreate) iterate this so they stay correct no matter how the split setting is
@@ -42,25 +42,26 @@ struct ContainerSandboxTests {
@Test func containerNameDerivesFromSessionShortID() {
let id = SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb")
#expect(ContainerManager.containerName(for: id) == "nucleic-abcdef12")
#expect(ContainerManager.containerName(for: id) == "nucleic-abcdef12\(ContainerManager.channelSuffix)")
}
/// `UUID().uuidString` is uppercase; the container name must be lowercased so it stays a stable,
/// collision-free key for the engine's registry and on-disk rootfs clones.
@Test func containerNameLowercasesUppercaseUUID() {
let id = SessionID(rawValue: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")
#expect(ContainerManager.containerName(for: id) == "nucleic-e621e1f8")
#expect(ContainerManager.containerName(for: id) == "nucleic-e621e1f8\(ContainerManager.channelSuffix)")
}
/// The shared control container names must be distinct from each other and from any per-session
/// container name otherwise a per-session `teardown` (which derives a `nucleic-<short>` name)
/// or the disk-GC reconcile could clobber the shared container out from under live sessions.
@Test func sharedControlContainerNamesAreDistinct() {
#expect(ContainerManager.sharedControlContainerName == "nucleic-control")
#expect(ContainerManager.codexControlContainerName == "nucleic-control-codex")
let s = ContainerManager.channelSuffix
#expect(ContainerManager.sharedControlContainerName == "nucleic-control\(s)")
#expect(ContainerManager.codexControlContainerName == "nucleic-control-codex\(s)")
#expect(ContainerManager.codexControlContainerName != ContainerManager.sharedControlContainerName)
#expect(ContainerManager.allSharedControlContainerNames ==
["nucleic-control", "nucleic-control-codex"])
["nucleic-control\(s)", "nucleic-control-codex\(s)"])
// A per-session name never equals a shared name.
let perSession = ContainerManager.containerName(
@@ -71,14 +72,48 @@ struct ContainerSandboxTests {
/// With splitting off, every backend shares `nucleic-control`; with it on, only the GPT/Codex
/// family moves to `nucleic-control-codex` while Claude stays on `nucleic-control`.
@Test func sharedContainerNameSplitsCodexFromClaude() {
let s = ContainerManager.channelSuffix
// Splitting off one shared container regardless of backend.
for backend in [BackendID.claudeCode, .codex, .codexExec] {
#expect(ContainerManager.sharedContainerName(for: backend, split: false) == "nucleic-control")
#expect(ContainerManager.sharedContainerName(for: backend, split: false) == "nucleic-control\(s)")
}
// Splitting on Codex family diverges; Claude stays put.
#expect(ContainerManager.sharedContainerName(for: .claudeCode, split: true) == "nucleic-control")
#expect(ContainerManager.sharedContainerName(for: .codex, split: true) == "nucleic-control-codex")
#expect(ContainerManager.sharedContainerName(for: .codexExec, split: true) == "nucleic-control-codex")
#expect(ContainerManager.sharedContainerName(for: .claudeCode, split: true) == "nucleic-control\(s)")
#expect(ContainerManager.sharedContainerName(for: .codex, split: true) == "nucleic-control-codex\(s)")
#expect(ContainerManager.sharedContainerName(for: .codexExec, split: true) == "nucleic-control-codex\(s)")
}
/// The per-channel suffix is one of the known channel tags empty for the stable release, a
/// `-`-prefixed tag otherwise and it lands at the very end of every container name so several
/// builds installed side-by-side don't collide on the shared on-disk container store. Channel is
/// fixed at build time, so this is channel-agnostic (the default `dev` test build resolves
/// `-local`; a `NUCLEIC_CHANNEL=stable` build resolves `""`).
@Test func channelSuffixTagsNonReleaseNames() {
let s = ContainerManager.channelSuffix
#expect(["", "-beta", "-rc", "-local"].contains(s))
// Only the stable release goes untagged; every other channel carries a tag.
#expect(s.isEmpty || s.hasPrefix("-"))
let perSession = ContainerManager.containerName(
for: SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb"))
#expect(perSession.hasSuffix(s))
#expect(ContainerManager.sharedControlContainerName.hasSuffix(s))
}
/// Launch-time disk GC must reap only this channel's clones from the shared on-disk store, so
/// `ownsContainer` claims names carrying this build's suffix and disowns every other channel's.
@Test func ownsOnlyThisChannelsContainers() {
// Names this build mints are always ours.
#expect(ContainerManager.ownsContainer(named: ContainerManager.sharedControlContainerName))
#expect(ContainerManager.ownsContainer(named: ContainerManager.codexControlContainerName))
#expect(ContainerManager.ownsContainer(named: ContainerManager.containerName(
for: SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb"))))
// A clone tagged for any *other* channel is never ours.
let foreign = (["", "-local", "-beta", "-rc"]).filter { $0 != ContainerManager.channelSuffix }
for fs in foreign {
#expect(!ContainerManager.ownsContainer(named: "nucleic-control\(fs)"))
#expect(!ContainerManager.ownsContainer(named: "nucleic-abcdef12\(fs)"))
}
}
// MARK: - sandbox config persistence