Files
nucleic/Sources/NucleicCore/Project.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

1078 lines
62 KiB
Swift

import Foundation
// MARK: - Project domain model (RUNTIME §4, PLAN "Core domain model")
// ProjectID moved to NucleicProtocol/CoreIdentifiers.swift (shared with the iOS client).
/// A git ref: a branch name, tag, or commit-ish. A thin wrapper so call sites read
/// intentionally (`GitRef("main")`) rather than passing bare strings everywhere.
public struct GitRef: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
public let value: String
public init(_ value: String) { self.value = value }
public init(stringLiteral value: String) { self.value = value }
public var description: String { value }
}
/// Setup-script failure policy (WORKTREE_MANAGER §5).
public enum SetupPolicy: String, Sendable, Codable {
/// Session can't start if setup fails (default — don't run agents against a broken env).
case block
/// Proceed but flag the failure.
case warn
}
/// Per-project execution-sandbox settings. When enabled, the project's sessions run
/// `claude` inside an isolated Linux VM (built on Apple's `containerization` framework) instead of
/// on the host, with the worktree bind-mounted (CONTAINER_SANDBOX). `nil` on a Project means
/// "off" — the default, host-spawned behavior.
public struct ProjectSandbox: Sendable, Codable, Equatable {
public var enabled: Bool
/// Image reference to run. `nil` → the bundled default image (built on first use).
public var image: String?
/// Stop the per-session container after this many seconds of inactivity; the next
/// turn transparently restarts it.
public var idleTimeoutSeconds: Int
/// Allow the sandboxed agent to build and run executables on the *host* machine via the
/// `host_exec` MCP tool, escaping the Linux container (e.g. to compile/run a macOS binary
/// the container can't). On by default for newly configured sandboxes; every host command
/// is approval-gated and is never auto-approved (HOST_EXEC). Only meaningful when `enabled`
/// is true. (Sandboxes persisted before this field existed still decode to `false` below.)
public var allowHostExec: Bool
/// Per-session sandbox containers. When false (the default), a Nucleic Control project's
/// sessions all share Nucleic's single **primary** managed container; when true, each
/// session gets its own container (today's per-session model). The choice is fixed at
/// project creation. NOT YET USER-SETTABLE — surfaced as a disabled placeholder toggle in
/// a control project's Settings; the creation-time migration that lets a project opt into
/// per-session containers is deferred. Only meaningful for Nucleic Control projects.
public var perSessionContainers: Bool
/// The default sandbox image, used when `image` is nil — a **registry reference** to the
/// prebuilt `nucleic-sandbox` image (built in CI from `containers/nucleic-sandbox/Dockerfile`
/// and pushed to GHCR; must be public for anonymous pulls). `ContainerEngine` pulls + unpacks it
/// and caches the rootfs by this exact ref, so bump the version tag whenever the Dockerfile
/// 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-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"
/// Where the VM's Linux kernel is fetched from when it isn't already on disk. Published as a GHCR
/// **package** — an OCI artifact carrying the single `vmlinux-arm64` blob, pushed by
/// `.github/workflows/kernel-image.yml` (NOT a GitHub release asset, which would inherit the repo's
/// visibility). A GHCR package's visibility is independent of the repo's, so this can be made
/// **public for anonymous pulls** while the repo stays private — exactly like the sandbox image
/// (`defaultImage`). When the package is private the app authenticates the pull with the user's
/// GitHub token (`read:packages`), host-scoped by the same `ContainerEngine.registryAuth` rule.
/// `ContainerEngine` first reuses a local/CLI kernel if present, so machines with Apple's
/// `container` installed download nothing. Bump the tag when the kernel version changes (and
/// publish the matching package).
public static let kernelImage = "ghcr.io/abkslm/nucleic-kernel:6.18.15-186"
public static let defaultIdleTimeoutSeconds = 900
public init(
enabled: Bool = false,
image: String? = nil,
idleTimeoutSeconds: Int = ProjectSandbox.defaultIdleTimeoutSeconds,
allowHostExec: Bool = true,
perSessionContainers: Bool = false
) {
self.enabled = enabled
self.image = image
self.idleTimeoutSeconds = idleTimeoutSeconds
self.allowHostExec = allowHostExec
self.perSessionContainers = perSessionContainers
}
// Tolerant decode: rows persisted before `allowHostExec` / `perSessionContainers` existed
// decode with the default rather than failing the whole blob — the store decodes sandbox
// config with `try?`, so a strict miss would silently drop the entire sandbox config.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
image = try c.decodeIfPresent(String.self, forKey: .image)
idleTimeoutSeconds =
try c.decodeIfPresent(Int.self, forKey: .idleTimeoutSeconds)
?? ProjectSandbox.defaultIdleTimeoutSeconds
allowHostExec = try c.decodeIfPresent(Bool.self, forKey: .allowHostExec) ?? false
perSessionContainers =
try c.decodeIfPresent(Bool.self, forKey: .perSessionContainers) ?? false
}
/// The image actually used to run containers (resolved override or bundled default).
public var resolvedImage: String {
if let image, !image.trimmingCharacters(in: .whitespaces).isEmpty { return image }
return ProjectSandbox.defaultImage
}
}
/// Per-project **nvrsion** settings (NVRSION, Beta). When active a control project's sessions
/// share a single `nucleic/trunk` checkout and land each edit into it immediately — instead of
/// each session forking an isolated worktree/branch held until ship (LOCKING). `nil` on a Project
/// (or `enabled == false`) means off — the default, classic per-session-worktree behavior.
///
/// Persisted as `nvrsion_config` JSON on the `project` row (mirrors `ProjectSandbox`), so new
/// fields are additive without a migration. Only meaningful for Nucleic Control projects that use
/// the shared control container — see `Project.nvrsionActive` (NVRSION §10).
public struct ProjectNvrsion: Sendable, Codable, Equatable {
/// Master switch for the mode. Default off (Beta, opt-in).
public var enabled: Bool
/// The shared trunk branch all the project's nvrsion sessions edit and land into.
public var trunkBranch: String
/// Optional fast validation command run after an edit is written and **before** it lands in
/// trunk (NVRSION §5). Receives the edited paths; non-zero exit rejects the edit back to the
/// agent (kept warm, not committed). `nil`/empty → land immediately (the default).
public var prelandHook: String?
/// Keep-warm idle window in seconds (NVRSION §4): a held file untouched this long is released
/// mid-turn so a waiter can take it. Lower = faster hand-off, more re-reads.
public var keepWarmIdleSeconds: Int
public static let defaultTrunkBranch = "nucleic/trunk"
public static let defaultKeepWarmIdleSeconds = 4
public init(
enabled: Bool = false,
trunkBranch: String = ProjectNvrsion.defaultTrunkBranch,
prelandHook: String? = nil,
keepWarmIdleSeconds: Int = ProjectNvrsion.defaultKeepWarmIdleSeconds
) {
self.enabled = enabled
self.trunkBranch = trunkBranch
self.prelandHook = prelandHook
self.keepWarmIdleSeconds = keepWarmIdleSeconds
}
// Tolerant decode: rows persisted before a field existed decode to its default rather than
// failing the whole blob (the store decodes with `try?`, so a strict miss drops the config).
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
trunkBranch =
try c.decodeIfPresent(String.self, forKey: .trunkBranch).flatMap {
$0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0
} ?? ProjectNvrsion.defaultTrunkBranch
prelandHook = try c.decodeIfPresent(String.self, forKey: .prelandHook)
keepWarmIdleSeconds =
try c.decodeIfPresent(Int.self, forKey: .keepWarmIdleSeconds)
?? ProjectNvrsion.defaultKeepWarmIdleSeconds
}
/// The pre-land hook to actually run, or `nil` when unset/blank (→ land immediately).
public var resolvedPrelandHook: String? {
guard let h = prelandHook?.trimmingCharacters(in: .whitespaces), !h.isEmpty else { return nil }
return h
}
}
/// App-wide container preferences, persisted in `UserDefaults`. These gate and seed the
/// *per-project* `ProjectSandbox` config above — they never sandbox sessions on their own.
/// Surfaced in Settings → General; read here so `NucleicCore` (e.g. `SessionController`,
/// `AppStore`) and the SwiftUI layer agree on the same keys.
/// How a Nucleic Control container session authenticates its agent. Shared by Claude
/// (``ContainerServiceSettings/controlAuthMode``) and Codex
/// (``ContainerServiceSettings/codexControlAuthMode``); the concrete credential store and env var
/// differ per provider.
public enum ControlAuthMode: String, CaseIterable, Sendable {
/// Reuse the host's subscription login — Claude's macOS Keychain OAuth credentials (exported as
/// `.credentials.json`) or Codex's `~/.codex/auth.json`, kept coherent host↔container by the
/// login-sync path. The default.
case oauth
/// Use a user-supplied API key (stored in the Keychain via `ControlAPIKeyStore` /
/// `CodexControlAPIKeyStore`), forwarded as `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`. For when the
/// host OAuth login doesn't work in the container, or the user would rather bill an API key.
case apiKey
}
/// How aggressively a running container's VM hands unused memory back to the host. The engine
/// attaches a virtio memory balloon to each VM and periodically retargets it from the guest's live
/// working set; this level chooses how much headroom to leave, how low it may shrink, and how often
/// it adjusts — single descriptive words rather than raw numeric knobs (those live in
/// ``BalloonPolicy``). Replaces the old "the VM never returns freed memory; restart to reclaim"
/// limitation with automatic reclamation.
public enum MemoryManagementLevel: String, CaseIterable, Sendable {
/// The default — reclaim gently: generous headroom, a high floor, slow cadence. Safest against
/// in-guest memory pressure; frees the least.
case conservative
/// Reclaim steadily with a comfortable safety margin.
case balanced
/// Reclaim tightly and react quickly — frees the most host memory, likeliest to squeeze a
/// spiky workload.
case aggressive
/// User-facing label (single word, capitalized).
public var label: String { rawValue.capitalized }
/// The concrete autoballoon parameters this level maps to.
public var policy: BalloonPolicy {
switch self {
case .conservative:
return BalloonPolicy(
enabled: true, headroom: 2.0, reserveBytes: 3 * 1_073_741_824 / 2,
floorBytes: 2 * 1_073_741_824, cadenceSeconds: 30, hysteresisBytes: 256 * 1_048_576)
case .balanced:
return BalloonPolicy(
enabled: true, headroom: 1.5, reserveBytes: 1_073_741_824,
floorBytes: 1_073_741_824, cadenceSeconds: 10, hysteresisBytes: 128 * 1_048_576)
case .aggressive:
return BalloonPolicy(
enabled: true, headroom: 1.25, reserveBytes: 3 * 1_073_741_824 / 4,
floorBytes: 512 * 1_048_576, cadenceSeconds: 5, hysteresisBytes: 64 * 1_048_576)
}
}
}
/// The concrete memory-balloon parameters a ``MemoryManagementLevel`` maps to. A pure value type so
/// the target math is unit-testable without booting a VM.
public struct BalloonPolicy: Sendable, Equatable {
/// `false` means no balloon is attached and memory is never reclaimed. Every current
/// ``MemoryManagementLevel`` enables it; the flag remains so a policy can still be built disabled.
public let enabled: Bool
/// Multiplier applied to the working set when picking the target (>1 leaves slack so the guest
/// isn't squeezed the instant it allocates again). Always ≥ 1, so the target never drops below
/// the working set — inflating a balloon below resident memory would force guest reclaim/OOM.
public let headroom: Double
/// Absolute bytes always kept available **on top of** the working set. The working set we measure
/// is the per-*container* cgroup figure, but the target is applied to the *whole VM*, which also
/// holds the guest kernel, vminitd, the init, the virtio overhead and the virtiofs page cache —
/// none of which are in that cgroup. Reserving a fixed whole-VM margin keeps the balloon from
/// inflating below the guest's true footprint (the bug that drove a swapless guest into a
/// direct-reclaim CPU spin). Sized to comfortably cover the kernel + vminitd + overhead + cache.
public let reserveBytes: UInt64
/// Never shrink the guest below this many bytes, however idle it looks — a floor against
/// thrashing the page cache or starving the kernel.
public let floorBytes: UInt64
/// Seconds between adjustment passes.
public let cadenceSeconds: UInt64
/// Only move the target when it changes by at least this much, to avoid churning the balloon.
public let hysteresisBytes: UInt64
public init(
enabled: Bool, headroom: Double, reserveBytes: UInt64, floorBytes: UInt64,
cadenceSeconds: UInt64, hysteresisBytes: UInt64
) {
self.enabled = enabled
self.headroom = headroom
self.reserveBytes = reserveBytes
self.floorBytes = floorBytes
self.cadenceSeconds = cadenceSeconds
self.hysteresisBytes = hysteresisBytes
}
/// Desired balloon target (guest memory in bytes) for a live container: leave the larger of a
/// `headroom` multiple of the working set and the working set plus a fixed whole-VM `reserveBytes`
/// margin — but never below `floorBytes` nor above the VM's configured `ceilingBytes`. The
/// additive reserve is what keeps the whole-VM target above the guest's real footprint when the
/// cgroup working set is small (where a pure multiplier would leave too little for the kernel/
/// page-cache and force reclaim).
public func target(workingSetBytes: UInt64, ceilingBytes: UInt64) -> UInt64 {
let scaledDouble = Double(workingSetBytes) * headroom
// Saturating cast: a huge product clamps to ceiling below anyway.
let scaled = scaledDouble >= Double(UInt64.max) ? UInt64.max : UInt64(scaledDouble)
let reserved = workingSetBytes.addingReportingOverflow(reserveBytes)
let withReserve = reserved.overflow ? UInt64.max : reserved.partialValue
return min(max(max(scaled, withReserve), floorBytes), ceilingBytes)
}
}
public enum ContainerServiceSettings {
/// Carries a UserDefaults across the task-local boundary: the type is documented thread-safe
/// but the SDK withholds its Sendable conformance, so the box vouches for it.
private struct DefaultsBox: @unchecked Sendable { let defaults: UserDefaults }
@TaskLocal private static var defaultsBox = DefaultsBox(defaults: .standard)
/// The store every accessor reads and `enableService()` writes. Production always resolves
/// to `.standard`; tests bind a fresh isolated suite via ``withDefaults(_:isolation:operation:)``
/// so they can flip these switches without ever mutating the process-global domain. Task-local
/// because swift-testing runs suites in parallel: a global (even save/restored) override of
/// `serviceEnabledKey` is visible to every concurrently running test, and a leaked `true`
/// sends SessionController tests down the real sandbox-provisioning path — seeding a
/// claude-home from the developer's `~/.claude` and blocking forever on the login-Keychain
/// ACL prompt when run headless.
public static var defaults: UserDefaults { defaultsBox.defaults }
/// Run `operation` with every ContainerServiceSettings accessor backed by `store`. The
/// binding is task-local: it flows into child tasks and `Task {}`s created inside, and is
/// invisible to concurrent tasks — the isolation seam tests bind a per-test suite through.
public static func withDefaults<R>(
_ store: UserDefaults,
isolation: isolated (any Actor)? = #isolation,
operation: () async throws -> R
) async rethrows -> R {
try await $defaultsBox.withValue(
DefaultsBox(defaults: store), operation: operation, isolation: isolation)
}
/// Master switch. Off → the per-project "run sessions in a sandbox container" option is
/// unavailable and no session runs in a container, regardless of its stored setting.
/// Turning it on only enables the *ability* to opt a project in; it does not sandbox
/// anything by itself. Off by default — the container runtime adds 2+ GB of memory
/// overhead per running session.
public static let serviceEnabledKey = "nucleic.container.serviceEnabled"
/// When on, newly added projects start with sandboxing enabled. Only takes effect while
/// the container service (above) is also on. Off by default.
public static let sandboxByDefaultKey = "nucleic.container.sandboxByDefault"
/// When on, newly *cloned* projects default to Nucleic Control (cloned under
/// `~/.nucleic/control/`, managed by Nucleic — shared sandbox, git interceptor,
/// autoship-eligible). Off by default. The Add-Project sheet seeds its Nucleic Control
/// switch from this.
public static let controlByDefaultKey = "nucleic.container.controlByDefault"
/// When on, new Nucleic Control projects start with **nvrsion** enabled (NVRSION) — the
/// shared-trunk versioning mode. Beta, off by default. Only meaningful for Control projects with
/// the container service on; a per-project toggle still overrides it.
public static let nvrsionByDefaultKey = "nucleic.container.nvrsionByDefault"
/// CPU count and memory (GiB) for Nucleic container VMs, split into two pools: the shared
/// primary Nucleic Control container serves *every* control session at once (so it wants
/// more), while a per-session container serves one. Applied via `container run --cpus N
/// --memory Ng`; `0`/unset → the sensible defaults. (`container`'s own defaults are 4 / 1 GiB.)
public static let controlContainerCPUsKey = "nucleic.container.control.cpus"
public static let controlContainerMemoryGiBKey = "nucleic.container.control.memoryGiB"
public static let sessionContainerCPUsKey = "nucleic.container.session.cpus"
public static let sessionContainerMemoryGiBKey = "nucleic.container.session.memoryGiB"
/// Per-session defaults (one session per VM) — also the `ContainerSpec` fallback.
public static let defaultContainerCPUs = 4
public static let defaultContainerMemoryGiB = 4
/// Shared primary control-container defaults (serves every control session) — more generous.
/// The memory constant is the documented floor; the *effective* default is host-aware (see
/// `recommendedControlContainerMemoryGiB`).
public static let defaultControlContainerCPUs = 8
public static let defaultControlContainerMemoryGiB = 8
/// Username for pulling the sandbox image from a private registry. The image is pulled with
/// HTTP Basic credentials = (this username, a token). On GHCR the username can be the GitHub
/// account (or any non-empty value with a valid token); when empty, the engine falls back to the
/// image-reference owner (e.g. `abkslm` from `ghcr.io/abkslm/…`). The token itself is the
/// app's GitHub token (`GitHubCredentialStore`, needs `read:packages` for GHCR) unless overridden
/// by the `NUCLEIC_REGISTRY_USER`/`NUCLEIC_REGISTRY_TOKEN` env. Public images (incl. Apple's
/// vminitd) are pulled anonymously regardless.
public static let registryUsernameKey = "nucleic.container.registry.username"
/// Configured private-registry username (UserDefaults), or `nil`/empty when unset.
public static var registryUsername: String? {
let v = (defaults.string(forKey: registryUsernameKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? nil : v
}
/// Default memory (GiB) for the shared Nucleic Control container: the host's *total* physical
/// RAM. It serves every Control session's `claude` process at once, so a fixed 8 GiB is the
/// most OOM-prone limit in the app — the in-VM Linux OOM-killer SIGKILLs a process the moment
/// the combined RSS hits the ceiling ("exited with status 137"). `--memory` is a *ceiling*, not
/// a reservation: Apple's `container` VM only consumes what it actually uses, so setting it to
/// the full system RAM just stops the in-VM OOM-killer from firing prematurely. The trade-off
/// (Apple's docs): the VM doesn't relinquish freed pages to the host while running, so a
/// long-lived busy container's footprint only ratchets up — reclaim it with `restartShared`
/// (Settings → Control → "Restart container"). The default argument reads the live host, so
/// call sites use `()`; tests pass `physicalBytes` explicitly.
public static func recommendedControlContainerMemoryGiB(
physicalBytes: UInt64 = ProcessInfo.processInfo.physicalMemory
) -> Int {
let physGiB = Int(physicalBytes / 1_073_741_824)
// = total system memory; fall back to the documented floor only on a bogus (0) reading.
return physGiB > 0 ? physGiB : defaultControlContainerMemoryGiB
}
/// Whether the container service is enabled app-wide.
public static var serviceEnabled: Bool {
defaults.bool(forKey: serviceEnabledKey)
}
/// Set once the app has finished its first-launch bootstrap, so the one-time remnant scan
/// (`AppStore.reconcileContainerService`) runs only on a genuinely fresh install and never
/// again. `bool(forKey:)` is false when absent, which is exactly "haven't launched yet".
public static let didCompleteFirstLaunchKey = "nucleic.firstLaunchCompleted"
/// Force the container-service master switch on. Called when a Nucleic Control project
/// exists — registered, or a leftover remnant on disk from a prior install — because Control
/// cannot function with the service off, so its presence pins the switch. Idempotent.
public static func enableService() {
defaults.set(true, forKey: serviceEnabledKey)
}
/// Whether new projects should default to sandboxing on. Only meaningful — and only
/// reported true — while the container service is enabled.
public static var sandboxByDefault: Bool {
serviceEnabled && defaults.bool(forKey: sandboxByDefaultKey)
}
/// Whether newly cloned projects default to Nucleic Control. Nucleic Control requires the
/// container service (its whole point is the shared sandbox container + git interceptor), so
/// this is only reported true while the service is on.
public static var controlByDefault: Bool {
serviceEnabled && defaults.bool(forKey: controlByDefaultKey)
}
/// Whether new Nucleic Control projects should start with nvrsion on (NVRSION). Beta; only
/// reported true while the container service is enabled (nvrsion needs the shared control
/// container). A per-project setting still overrides this default.
public static var nvrsionByDefault: Bool {
serviceEnabled && defaults.bool(forKey: nvrsionByDefaultKey)
}
/// A stored integer for `key`, clamped to ≥ 1, falling back when unset/invalid.
private static func resource(_ key: String, default fallback: Int) -> Int {
let v = defaults.integer(forKey: key)
return v > 0 ? v : fallback
}
/// CPUs / memory (GiB) for the shared primary Nucleic Control container.
public static var controlContainerCPUs: Int {
resource(controlContainerCPUsKey, default: defaultControlContainerCPUs)
}
public static var controlContainerMemoryGiB: Int {
// Unset → the host-aware recommendation (not the flat floor), so the OOM-prone shared
// container gets headroom by default on capable hosts.
resource(controlContainerMemoryGiBKey, default: recommendedControlContainerMemoryGiB())
}
/// CPUs / memory (GiB) for a per-session container.
public static var sessionContainerCPUs: Int {
resource(sessionContainerCPUsKey, default: defaultContainerCPUs)
}
public static var sessionContainerMemoryGiB: Int {
resource(sessionContainerMemoryGiBKey, default: defaultContainerMemoryGiB)
}
/// How Nucleic Control container sessions authenticate `claude` — the host subscription
/// login (OAuth, default) or a user-supplied API key (`ControlAPIKeyStore`). App-wide for
/// now; a per-project override is planned (see the hint in project settings).
public static let controlAuthModeKey = "nucleic.container.control.authMode"
public static var controlAuthMode: ControlAuthMode {
ControlAuthMode(rawValue: defaults.string(forKey: controlAuthModeKey) ?? "")
?? .oauth
}
/// How Nucleic Control container sessions authenticate `codex` — the host ChatGPT-subscription
/// login (OAuth, default; kept in sync host↔container by ``CodexAuthFile``) or a user-supplied
/// OpenAI API key (`CodexControlAPIKeyStore`, forwarded as `OPENAI_API_KEY`). The API-key mode is
/// the escape hatch when the shared OAuth refresh token keeps getting revoked across environments.
public static let codexControlAuthModeKey = "nucleic.container.control.codex.authMode"
public static var codexControlAuthMode: ControlAuthMode {
ControlAuthMode(rawValue: defaults.string(forKey: codexControlAuthModeKey) ?? "")
?? .oauth
}
/// When on, each agent family gets its *own* shared Nucleic Control container instead of all
/// piling into the one `nucleic-control`. The model families can otherwise turn competitive in
/// a shared sandbox — killing each other's processes ("agenticide") — so this isolates them into
/// `nucleic-control-claude` (Claude), `nucleic-control-codex` (GPT/Codex), and
/// `nucleic-control-xai` (xAI/Grok). Off by default; only consulted for shared-container control
/// sessions (per-session containers are already isolated).
/// See `ContainerManager.sharedContainerName(for:split:)`.
public static let splitControlContainersByBackendKey = "nucleic.container.control.splitByBackend"
public static var splitControlContainersByBackend: Bool {
defaults.bool(forKey: splitControlContainersByBackendKey)
}
// 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
/// tool call and batch-posts the metadata to the GUI command feed. Because the `DEBUG` trap fires
/// before every simple command, it adds per-command shell overhead during build/test-heavy turns
/// — so it is **opt-in (default off)**. This gates only the command tracer: the git/gh
/// interception the conflict-detection / merge-queue / autoship system relies on is always on.
/// Gated at the env level (the `BASH_ENV`/command-hook vars are injected only when this is on),
/// so toggling takes effect for agent turns started after the change — no container rebuild.
public static let commandTracingEnabledKey = "nucleic.container.commandTracing"
public static var commandTracingEnabled: Bool {
defaults.bool(forKey: commandTracingEnabledKey)
}
/// How aggressively running container VMs reclaim unused memory to the host (via each VM's
/// virtio memory balloon, driven by `ContainerEngine`'s autoballoon loop). Defaults to
/// `.conservative`.
///
/// Conservative by default — the gentlest reclaiming level (generous headroom, a high floor, slow
/// cadence). Ballooning was historically off out of the box because the balloon's target is
/// derived from the per-**container** cgroup working set but applied to the **whole VM**
/// (`targetVirtualMachineMemorySize`), which also hosts the guest kernel, vminitd, init and the
/// virtiofs page cache. With no guest swap, a turn that allocated past the (cadence-stale) target
/// sent the guest into a perpetual direct-reclaim spin — a guest vCPU pegged at ~100% and the
/// agent wedged mid-allocation (output froze and never recovered, ending in OOM-137).
/// ``BalloonPolicy/target(workingSetBytes:ceilingBytes:)`` now reserves a whole-VM margin so every
/// level clears the guest's true footprint, so the default reclaims conservatively rather than not
/// at all. Unset/invalid → the default.
public static let memoryManagementKey = "nucleic.container.memoryManagement"
public static var memoryManagement: MemoryManagementLevel {
MemoryManagementLevel(rawValue: defaults.string(forKey: memoryManagementKey) ?? "")
?? .conservative
}
/// Whether a sandboxed agent may spin up its OWN throwaway Linux containers (the `linux_container`
/// tool) for lightweight work it wants isolated from its main sandbox — a fresh copy of the same
/// image, managed by the agent (create/exec/stop/remove). Containers are the lightest rung of the
/// virtualization ladder (far cheaper than a Linux or macOS VM), so this is ON by default whenever
/// the container service is on. Turning it off hides the tool without disabling the agent's own
/// primary sandbox container.
public static let agentContainersEnabledKey = "nucleic.container.agentContainersEnabled"
/// Whether sandboxed agents are offered the `linux_container` tool (defaults to `true` when unset;
/// an explicit stored `false` still wins). Only meaningful while the container service is on.
public static var agentContainersEnabled: Bool {
serviceEnabled && (defaults.object(forKey: agentContainersEnabledKey) as? Bool ?? true)
}
}
/// Which guest a *general* (OS-agnostic) computer-use task should default to when both the Linux and
/// macOS VM services are available. Mac-only work (driving Xcode, a macOS/iOS app) always uses the
/// macOS VM regardless; this only settles the tie for tasks that could run on either desktop. Linux
/// is the default because a Linux guest is lighter and boots faster (docs/LINUX_VM.md). Surfaced in
/// Settings ▸ Virtual Machines and fed into the agent's build guidance so it reaches for the right VM.
public enum ComputerUseVMType: String, CaseIterable, Sendable, Codable {
case linux
case macOS
/// Human label for the settings picker and prompt guidance.
public var label: String {
switch self {
case .linux: return "Linux"
case .macOS: return "macOS"
}
}
}
/// App-wide settings for the **macOS-guest VM** service (Apple `Virtualization` framework) — the
/// Mac-native sibling of ``ContainerServiceSettings``. Where the Linux container gives agents the
/// cross-platform toolchain, a macOS VM gives them a real, isolated Mac (Xcode, the simulators,
/// codesign) for end-to-end testing — one per session, so several agents run Mac work at once
/// without colliding on the single shared host the way `host_exec` does.
///
/// Reads through ``ContainerServiceSettings/defaults`` so the same task-local test-suite binding
/// (`ContainerServiceSettings.withDefaults`) isolates these switches too, and so production always
/// resolves to `.standard`.
public enum MacVMSettings {
private static var defaults: UserDefaults { ContainerServiceSettings.defaults }
/// Master switch. Off → the `mac_vm_exec` tool is never exposed and no macOS VM is booted,
/// regardless of any per-session setting. Off by default: a macOS guest is heavy (several GB of
/// host RAM each, plus a large one-time base-image install), so it's strictly opt-in.
public static let serviceEnabledKey = "nucleic.macvm.serviceEnabled"
/// When on, sandboxed sessions expose `mac_vm_exec` (their own isolated macOS VM) by default.
/// Only meaningful while the macOS-VM service (above) is on. On by default.
public static let exposeByDefaultKey = "nucleic.macvm.exposeByDefault"
/// When on, sandboxed sessions also expose `mac_vm_computer` — GUI computer-use (screenshots +
/// mouse/keyboard) driving the VM as a Mac dev simulator. A SEPARATE opt-in from `exposeByDefault`.
/// Capture + input run **host-side** through the guest's virtual display + virtual HID
/// (docs/MACOS_VM.md §12), so no in-guest permissions and no SIP are required. On by default;
/// only meaningful while the service is on.
public static let computerUseByDefaultKey = "nucleic.macvm.computerUseByDefault"
/// When on, the base image ALSO installs the optional in-guest **semantic AX agent**
/// (`NucleicVMAgent`): Accessibility-based control (act on a UI element by identity rather than
/// screen coordinates). It needs the guest's TCC grants, which are written automatically during the
/// base build (docs/MACOS_VM.md §12.5). OFF by default: the DEFAULT computer-use path is host-side
/// virtual IO, which needs neither the agent nor those grants. Only meaningful while the service is on.
public static let axAgentEnabledKey = "nucleic.macvm.axAgentEnabled"
/// When on, the in-chat **VM Monitor** panel is opened automatically in the right column whenever
/// the open chat has a VM running, and retracted again the moment the user switches to a chat that
/// has no VM — so the guest screen follows the conversation without manually adding the panel.
/// OFF by default. Only meaningful while a guest service (macOS or Linux) is on.
public static let autoOpenVMMonitorsKey = "nucleic.macvm.autoOpenVMMonitors"
/// Optional path to a **prebuilt** golden base VM bundle the user produced out-of-band. When set
/// and present, the engine clones it directly and skips the install+provision path entirely. Empty
/// → the engine builds its own golden base under the storage root (install from the restore image,
/// then provision the toolchain).
public static let basePrebuiltPathKey = "nucleic.macvm.basePrebuiltPath"
/// Optional path to a local macOS restore `.ipsw`. Empty → the engine fetches
/// `VZMacOSRestoreImage.latestSupported` over the network (~14 GB) the first time it builds a base.
/// The `.ipsw` you supply pins the guest macOS version (e.g. a macOS 27 restore image).
public static let restoreImagePathKey = "nucleic.macvm.restoreImagePath"
/// Optional **remote** `.ipsw` URL to pin a specific guest macOS version without pre-downloading it
/// by hand — e.g. a macOS 27 UniversalMac restore image. The engine downloads it,
/// caches it keyed by filename (so switching versions doesn't collide), and installs from it.
/// Takes precedence over `latestSupported` but not over an explicit local `restoreImagePath`. Empty
/// → use `latestSupported` (the newest the host can run; see docs/MACOS_VM.md §guest versions).
public static let restoreImageURLKey = "nucleic.macvm.restoreImageURL"
/// UI-only: which entry is selected in the guest-image picker (a stable id from the hardcoded
/// IPSW catalog, or "" for “latest supported”). The engine reads `restoreImageURL` /
/// `restoreImagePath`; this just remembers the picker position so a chosen entry stays selected
/// across launches (including placeholder entries whose `.ipsw` isn't published yet).
public static let restoreImageChoiceKey = "nucleic.macvm.restoreImageChoice"
/// Per-session guest resource ceilings. Applied to the cloned VM at boot; `0`/unset → the defaults.
public static let vmCPUsKey = "nucleic.macvm.cpus"
public static let vmMemoryGiBKey = "nucleic.macvm.memoryGiB"
/// Base-disk size (GiB) the golden base is installed into; clones inherit it copy-on-write.
public static let baseDiskGiBKey = "nucleic.macvm.baseDiskGiB"
/// Ceiling on concurrently *running* macOS guests. macOS has historically capped simultaneous
/// macOS-guest VMs (2 on recent releases); the manager enforces this so a fan-out of agents
/// queues rather than failing an over-limit `VZVirtualMachine.start`. Adjust if a future OS lifts it.
public static let maxConcurrentVMsKey = "nucleic.macvm.maxConcurrent"
/// How long (seconds) a session's VM boot waits in the admission queue for a slot to free when the
/// concurrent-VM ceiling is hit and no *done* chat's VM can be reclaimed, before giving up with the
/// concurrency-limit error. Bounds the wait so a tool call can't hang indefinitely behind actively
/// working peers. Only reached when every occupied slot is a busy chat (a done chat's VM is evicted
/// first, never queued behind). See ``MacVMEngine/ensureRunning(_:)``.
public static let vmQueueTimeoutSecondsKey = "nucleic.macvm.queueTimeoutSeconds"
/// The in-guest account Nucleic SSHes in as (baked into the base by provisioning).
public static let sshUserKey = "nucleic.macvm.sshUser"
public static let defaultVMCPUs = 4
/// macOS guests want more than a Linux container: 8 GiB keeps Xcode/simulator responsive.
public static let defaultVMMemoryGiB = 8
public static let defaultBaseDiskGiB = 96
public static let defaultIdleTimeoutSeconds = 900
public static let defaultMaxConcurrentVMs = 2
/// Default admission-queue wait (see ``vmQueueTimeoutSecondsKey``): three minutes — long enough to
/// ride out a peer's turn wrapping up, short enough that a full house surfaces the ceiling error
/// rather than stalling the tool call indefinitely.
public static let defaultVMQueueTimeoutSeconds = 180
public static let defaultSSHUser = "agent"
/// Whether the macOS-VM service is enabled app-wide.
public static var serviceEnabled: Bool { defaults.bool(forKey: serviceEnabledKey) }
/// Whether sandboxed sessions expose `mac_vm_exec` by default (only while the service is on).
/// Defaults to `true` when unset; an explicit stored `false` still wins (`object(forKey:)`
/// distinguishes "unset" from a stored `false`, which `bool(forKey:)` cannot).
public static var exposeByDefault: Bool {
serviceEnabled && (defaults.object(forKey: exposeByDefaultKey) as? Bool ?? true)
}
/// Whether sandboxed sessions expose `mac_vm_computer` (GUI computer-use) by default (only while
/// the service is on). Host-side virtual IO, so any installed base can serve it — no provisioning.
/// Defaults to `true` when unset; an explicit stored `false` still wins.
public static var computerUseByDefault: Bool {
serviceEnabled && (defaults.object(forKey: computerUseByDefaultKey) as? Bool ?? true)
}
/// Whether the base image installs the optional in-guest semantic AX agent (the by-identity
/// control add-on; see ``axAgentEnabledKey``). Builds on virtual computer use, so it requires
/// both the service and ``computerUseByDefault`` to be on.
public static var axAgentEnabled: Bool {
computerUseByDefault && defaults.bool(forKey: axAgentEnabledKey)
}
/// Whether the in-chat VM Monitor should follow the open chat — auto-opening in the right column
/// while that chat has a VM up and closing when it doesn't (see ``autoOpenVMMonitorsKey``). Only
/// meaningful while a guest service (macOS or Linux) is on.
public static var autoOpenVMMonitors: Bool {
(serviceEnabled || linuxServiceEnabled) && defaults.bool(forKey: autoOpenVMMonitorsKey)
}
/// Configured prebuilt-base path, or `nil`/empty when unset.
public static var basePrebuiltPath: String? {
let v = (defaults.string(forKey: basePrebuiltPathKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? nil : v
}
/// Configured local restore-image path, or `nil`/empty when unset.
public static var restoreImagePath: String? {
let v = (defaults.string(forKey: restoreImagePathKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? nil : v
}
/// Configured remote restore-image `.ipsw` URL (version pin), or `nil`/empty when unset.
public static var restoreImageURL: String? {
let v = (defaults.string(forKey: restoreImageURLKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? nil : v
}
/// Configured SSH account name, or the default.
public static var sshUser: String {
let v = (defaults.string(forKey: sshUserKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? defaultSSHUser : v
}
/// Optional password for the in-guest `agent` account, used by the macOS-27 **declarative
/// first-boot provisioning** path (`VZMacGuestProvisioningOptions` — the guest account,
/// auto-login, and Remote Login are created unattended when host AND guest are macOS 27+).
/// Empty → the engine generates a random password and persists it next to the host SSH key
/// (`macvms/ssh/agent-password`), so the fully-unattended path needs no configuration. The
/// guest is a NAT-isolated, disposable VM — this is a lab credential, not a secret vault.
public static let agentPasswordKey = "nucleic.macvm.agentPassword"
/// Configured agent-account password, or `nil`/empty when unset (engine then generates one).
public static var agentPassword: String? {
let v = (defaults.string(forKey: agentPasswordKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? nil : v
}
/// Absolute host paths to user-provided `.app` bundles to bake into the base image's
/// `/Applications`. Chosen in Settings ("Base image" → "Included apps", drag-drop or browse) and
/// applied during the base build/re-provision: the engine stages each into the provisioning share
/// and the guest provisioner copies them into `/Applications` (docs/MACOS_VM.md §4.4). Stored as a
/// plain `[String]` (UserDefaults persists string arrays natively).
public static let bundledAppPathsKey = "nucleic.macvm.bundledAppPaths"
/// Configured `.app` bundle paths to include in the base, filtered to those that still exist on
/// disk and end in `.app` (a bundle the user moved/deleted since selecting it is silently dropped).
public static var bundledAppPaths: [String] {
let fm = FileManager.default
return (defaults.stringArray(forKey: bundledAppPathsKey) ?? []).filter { path in
path.hasSuffix(".app") && fm.fileExists(atPath: path)
}
}
/// Persist the list of `.app` bundle paths to include in the base image (see ``bundledAppPaths``).
public static func setBundledAppPaths(_ paths: [String]) {
defaults.set(paths, forKey: bundledAppPathsKey)
}
/// Ids of the **common packages** (``MacVMPackage``) the operator opted into installing in the base
/// image — Settings ("macOS virtual machines" → "Common packages"). Unlike ``bundledAppPaths``
/// (local `.app` bundles the user supplies), these are well-known tools the guest fetches and
/// installs itself during provisioning (e.g. the latest Chrome). Stored as a plain `[String]`.
public static let selectedPackagesKey = "nucleic.macvm.selectedPackages"
/// Selected common-package ids, filtered to catalog entries that are actually installable (an
/// unknown or not-yet-supported/grayed-out id is silently dropped — a placeholder can never be
/// selected). Order follows the stored selection.
public static var selectedPackageIDs: [String] {
let installable = Set(MacVMPackage.catalog.filter { $0.isInstallable }.map { $0.id })
return (defaults.stringArray(forKey: selectedPackagesKey) ?? []).filter { installable.contains($0) }
}
/// Persist the selected common-package ids (see ``selectedPackageIDs``).
public static func setSelectedPackageIDs(_ ids: [String]) {
defaults.set(ids, forKey: selectedPackagesKey)
}
private static func resource(_ key: String, default fallback: Int) -> Int {
let v = defaults.integer(forKey: key)
return v > 0 ? v : fallback
}
public static var vmCPUs: Int { resource(vmCPUsKey, default: defaultVMCPUs) }
public static var vmMemoryGiB: Int { resource(vmMemoryGiBKey, default: defaultVMMemoryGiB) }
public static var baseDiskGiB: Int { resource(baseDiskGiBKey, default: defaultBaseDiskGiB) }
public static var maxConcurrentVMs: Int {
resource(maxConcurrentVMsKey, default: defaultMaxConcurrentVMs)
}
public static var vmQueueTimeoutSeconds: Int {
resource(vmQueueTimeoutSecondsKey, default: defaultVMQueueTimeoutSeconds)
}
// MARK: - Linux guests
/// Master switch for the **Linux** VM service — the sibling of ``serviceEnabledKey``. Off by
/// default: a Linux guest is a separate, heavy opt-in (its own multi-GB base build). When off, no
/// Linux VM is booted and the Linux base build is unavailable, regardless of per-session settings.
public static let linuxServiceEnabledKey = "nucleic.linuxvm.serviceEnabled"
/// When on, sandboxed sessions expose exec against their own isolated **Linux** VM by default (only
/// while the Linux service is on). On by default.
public static let linuxExposeByDefaultKey = "nucleic.linuxvm.exposeByDefault"
/// When on, sandboxed Linux-VM sessions also expose computer-use (host-side virtio-gpu capture +
/// USB HID). On by default; only meaningful while the Linux service is on.
public static let linuxComputerUseByDefaultKey = "nucleic.linuxvm.computerUseByDefault"
/// When on, the Linux base ALSO enables the in-guest **semantic (AT-SPI) agent** — element-level
/// UI understanding (act on a control by identity rather than screen coordinates; see
/// docs/LINUX_VM_SEMANTIC_AGENT.md). The sibling of ``axAgentEnabledKey``. Builds on Linux
/// kernel-level computer use, so it requires both the service and computer-use to be on. OFF by
/// default: the default computer-use path is host-side virtio IO, which needs neither.
public static let linuxAxAgentEnabledKey = "nucleic.linuxvm.axAgentEnabled"
/// URLs for the Linux boot artifacts. The kernel is an arm64 `Image`; if the source is a gzip
/// `vmlinuz` (Ubuntu ships these), the engine gunzips it host-side before boot. Empty → the pinned
/// Ubuntu arm64 defaults below, so a Linux base builds with zero configuration.
public static let linuxKernelURLKey = "nucleic.linuxvm.kernelURL"
public static let linuxInitrdURLKey = "nucleic.linuxvm.initrdURL"
public static let linuxRootfsURLKey = "nucleic.linuxvm.rootfsURL"
/// Optional path to a **prebuilt** Linux base bundle (kernel + initrd + provisioned rootfs). When
/// set and complete, the engine clones it directly and skips the download+build path.
public static let linuxBasePrebuiltPathKey = "nucleic.linuxvm.basePrebuiltPath"
/// Pinned Ubuntu arm64 defaults (**26.04 LTS "resolute"**). Cloud-image kernel + initrd
/// (self-consistent with the rootfs's kernel modules) and the cloud root filesystem tarball. The
/// latest LTS: long support window, and it ships **GNOME 50 / Mutter** on Wayland — the compositor
/// the semantic (accessibility) agent targets (docs/LINUX_VM_SEMANTIC_AGENT.md §3, §Phase 3). A
/// GNOME bump is a deliberate, scoped port.
///
/// Kernel note: modern Ubuntu arm64 `vmlinuz` is an **EFI-zboot** self-decompressing PE (zstd
/// payload), which `VZLinuxBootLoader` cannot boot — it needs a raw arm64 `Image`. The artifact
/// pipeline unwraps zboot host-side (``MacVMEngine.prepareBootableKernel``). To skip that (e.g. to
/// avoid the host `zstd` dependency), override ``linuxKernelURLKey`` with a **pre-unwrapped** raw
/// Image — see `scripts/publish-linux-vm-kernel.sh` for producing/publishing one.
public static let defaultLinuxKernelURL =
"https://cloud-images.ubuntu.com/releases/resolute/release/unpacked/"
+ "ubuntu-26.04-server-cloudimg-arm64-vmlinuz-generic"
public static let defaultLinuxInitrdURL =
"https://cloud-images.ubuntu.com/releases/resolute/release/unpacked/"
+ "ubuntu-26.04-server-cloudimg-arm64-initrd-generic"
public static let defaultLinuxRootfsURL =
"https://cloud-images.ubuntu.com/releases/resolute/release/"
+ "ubuntu-26.04-server-cloudimg-arm64-root.tar.xz"
/// Whether the Linux-VM service is enabled app-wide.
public static var linuxServiceEnabled: Bool { defaults.bool(forKey: linuxServiceEnabledKey) }
/// Whether sandboxed sessions expose a Linux VM by default (only while the Linux service is on).
public static var linuxExposeByDefault: Bool {
linuxServiceEnabled && (defaults.object(forKey: linuxExposeByDefaultKey) as? Bool ?? true)
}
/// Whether sandboxed Linux-VM sessions expose computer-use by default (only while on).
public static var linuxComputerUseByDefault: Bool {
linuxServiceEnabled && (defaults.object(forKey: linuxComputerUseByDefaultKey) as? Bool ?? true)
}
/// Whether the Linux base enables the in-guest semantic (AT-SPI) agent (see
/// ``linuxAxAgentEnabledKey``). Builds on Linux kernel-level computer use, so it requires both the
/// service and ``linuxComputerUseByDefault`` to be on.
public static var linuxAxAgentEnabled: Bool {
linuxComputerUseByDefault && defaults.bool(forKey: linuxAxAgentEnabledKey)
}
private static func linuxURL(_ key: String, default fallback: String) -> String {
let v = (defaults.string(forKey: key) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? fallback : v
}
public static var linuxKernelURL: String { linuxURL(linuxKernelURLKey, default: defaultLinuxKernelURL) }
public static var linuxInitrdURL: String { linuxURL(linuxInitrdURLKey, default: defaultLinuxInitrdURL) }
public static var linuxRootfsURL: String { linuxURL(linuxRootfsURLKey, default: defaultLinuxRootfsURL) }
/// Configured prebuilt Linux base path, or `nil`/empty when unset.
public static var linuxBasePrebuiltPath: String? {
let v = (defaults.string(forKey: linuxBasePrebuiltPathKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? nil : v
}
// MARK: - Default computer-use guest
/// Which guest a *general* computer-use task defaults to (``ComputerUseVMType``). Only settles the
/// tie for OS-agnostic desktop work — Mac-only computer use always uses the macOS VM. Empty/invalid
/// → Linux (the lighter, faster-booting default).
public static let defaultComputerUseVMTypeKey = "nucleic.vm.defaultComputerUseType"
/// The configured default computer-use guest, or ``ComputerUseVMType/linux`` when unset/invalid.
public static var defaultComputerUseVMType: ComputerUseVMType {
ComputerUseVMType(rawValue: defaults.string(forKey: defaultComputerUseVMTypeKey) ?? "")
?? .linux
}
}
/// A registered git repository plus per-project configuration.
public struct Project: Identifiable, Sendable, Codable, Equatable {
public let id: ProjectID
public var name: String
public var rootPath: String
public var defaultBranch: GitRef
/// The branch autoship merges this project's sessions into. `nil` → the project root
/// repo's `defaultBranch` (the default). A per-session override (`Session.shipBranch`)
/// takes precedence over this. See `resolvedAutoShipBranch`.
public var autoShipBranch: GitRef?
public var defaultBackend: BackendID?
/// Override for where session worktrees are created. `nil` → the default
/// sibling dir on the same volume (WORKTREE_MANAGER §2).
public var worktreeBase: String?
public var setupScript: String?
public var setupPolicy: SetupPolicy
/// Execution-sandbox settings. `nil` → sessions run on the host (default).
public var sandbox: ProjectSandbox?
/// nvrsion (Beta) settings. `nil`/`enabled == false` → classic per-session-worktree behavior
/// (the default). See `nvrsionActive` for the full gate (NVRSION §10).
public var nvrsion: ProjectNvrsion?
public var createdAt: Date
/// When the project was archived, or `nil` if it's active. Archiving hides the project
/// from the active list and stops its live sessions, but leaves all files and records in
/// place so it can be restored (see `AppStore.setProjectArchived`).
public var archivedAt: Date?
public init(
id: ProjectID = .generate(),
name: String,
rootPath: String,
defaultBranch: GitRef,
autoShipBranch: GitRef? = nil,
defaultBackend: BackendID? = .claudeCode,
worktreeBase: String? = nil,
setupScript: String? = nil,
setupPolicy: SetupPolicy = .block,
sandbox: ProjectSandbox? = nil,
nvrsion: ProjectNvrsion? = nil,
createdAt: Date = Date(),
archivedAt: Date? = nil
) {
self.id = id
self.name = name
self.rootPath = rootPath
self.defaultBranch = defaultBranch
self.autoShipBranch = autoShipBranch
self.defaultBackend = defaultBackend
self.worktreeBase = worktreeBase
self.setupScript = setupScript
self.setupPolicy = setupPolicy
self.sandbox = sandbox
self.nvrsion = nvrsion
self.createdAt = createdAt
self.archivedAt = archivedAt
}
/// Whether this project is currently archived (hidden from the active list).
public var isArchived: Bool { archivedAt != nil }
/// The branch autoship merges this project's sessions into, resolved: the configured
/// `autoShipBranch` if set, else the project root repo's `defaultBranch`.
public var resolvedAutoShipBranch: GitRef { autoShipBranch ?? defaultBranch }
/// Whether this repo lives under Nucleic's control directory (`~/.nucleic/control/`) —
/// a repo Nucleic clones and manages itself, kept out of iCloud and away from manual
/// edits. Derived from `rootPath`, so a move into/out of the control dir flips it.
public var isNucleicControlled: Bool {
ProjectCloner.isControlled(rootPath)
}
/// The sandbox config that actually governs this project's sessions, resolving the Nucleic
/// Control default: a **controlled** project is sandboxed project-wide by default — even
/// with no stored `sandbox`, and even if a stored one is disabled — so it always runs in
/// Nucleic's managed container (its `image`/`idleTimeout`/`perSessionContainers` carry
/// through when set). A non-controlled project keeps the explicit opt-in: its stored
/// `sandbox` when enabled, else `nil` (host-spawned).
///
/// This is the single source of truth both the spawn gate (`SessionController`) and the
/// teardown paths (`AppStore`) read, so they can't drift. It is still **subordinate to**
/// the app-wide `ContainerServiceSettings.serviceEnabled` master switch, which callers
/// check first — so with the container service off, even a control project runs on the host.
public var effectiveSandbox: ProjectSandbox? {
if isNucleicControlled {
var s = sandbox ?? ProjectSandbox()
s.enabled = true
return s
}
if let sandbox, sandbox.enabled { return sandbox }
return nil
}
/// Whether this project's sandboxed sessions share Nucleic's single **primary** managed
/// container rather than each getting their own. True only for a Nucleic Control project
/// that hasn't opted into per-session containers — the default for control projects.
public var usesSharedControlContainer: Bool {
isNucleicControlled && !(effectiveSandbox?.perSessionContainers ?? false)
}
/// Whether **nvrsion** (Beta) governs this project's sessions — the single gate both the spawn
/// path and teardown read so they can't drift (NVRSION §10). Requires a Nucleic Control project,
/// the mode enabled, and the shared control container (v0 doesn't support per-session
/// containers). Like `effectiveSandbox` it stays subordinate to the app-wide container-service
/// master switch, which callers check first.
public var nvrsionActive: Bool {
isNucleicControlled && (nvrsion?.enabled ?? false) && usesSharedControlContainer
}
/// Host path of the shared nvrsion trunk checkout: `<repoRoot>/.nucleic/trunk` — kept inside the
/// project under `.nucleic/` (git-excluded) on the same volume, beside the worktrees dir.
public var resolvedTrunkPath: String {
let nucleicDir = (rootPath as NSString).appendingPathComponent(".nucleic")
return (nucleicDir as NSString).appendingPathComponent("trunk")
}
/// `<repoSlug>` used in the worktree path and branch derivation.
public var repoSlug: String {
let last = (rootPath as NSString).lastPathComponent
return Slug.sanitize(last, fallback: "repo")
}
/// Resolved base directory for this project's session worktrees:
/// `<override>/<repoSlug>` or the default `<repoRoot>/.nucleic/worktrees` — kept
/// inside the project under `.nucleic/` (git-excluded so the primary checkout never
/// sees it) on the same volume (WORKTREE_MANAGER §2).
public var resolvedWorktreeBase: String {
if let worktreeBase {
return (worktreeBase as NSString).appendingPathComponent(repoSlug)
}
let nucleicDir = (rootPath as NSString).appendingPathComponent(".nucleic")
return (nucleicDir as NSString).appendingPathComponent("worktrees")
}
}
/// A compact, `Sendable` view of a project *anywhere in the mesh* for the sidebar — the project
/// analogue of `SessionSummary`. Local projects and projects mirrored from peer Macs both map
/// into this one shape, so the sidebar renders a single list through a single code path and
/// differentiates origin only by a globe badge. Semantics a local `Project` *derives* from its
/// paths/defaults (`isNucleicControlled`, `nvrsionActive`) are carried as plain values here,
/// because a remote project's truth rides the wire, not this Mac's filesystem.
public struct ProjectSummary: Identifiable, Sendable, Equatable, Hashable {
public let id: ProjectID
public var name: String
public var isNucleicControlled: Bool
public var nvrsionActive: Bool
/// Whether sessions run in a sandbox container (drives the shield badge). Always false for
/// remote projects — the wire doesn't carry it, and the shield describes *this* Mac's runtime.
public var sandboxed: Bool
/// The `HostID` of the peer Mac this project lives on, or `nil` for a project on this Mac.
public var hostID: String?
/// The owning Mac's friendly name (resolved when the summary is built), for the globe
/// badge's tooltip. `nil` for local projects.
public var hostLabel: String?
public var isRemote: Bool { hostID != nil }
/// List-row identity, origin-qualified for the same reason as `SessionSummary.sidebarRowID`.
public var rowID: String { "\(hostID ?? "local")-\(id.rawValue)" }
public init(
id: ProjectID, name: String, isNucleicControlled: Bool = false,
nvrsionActive: Bool = false, sandboxed: Bool = false,
hostID: String? = nil, hostLabel: String? = nil
) {
self.id = id
self.name = name
self.isNucleicControlled = isNucleicControlled
self.nvrsionActive = nvrsionActive
self.sandboxed = sandboxed
self.hostID = hostID
self.hostLabel = hostLabel
}
public init(_ project: Project) {
self.init(
id: project.id, name: project.name,
isNucleicControlled: project.isNucleicControlled,
nvrsionActive: project.nvrsionActive,
sandboxed: project.sandbox?.enabled == true)
}
}
// MARK: - Slug derivation (WORKTREE_MANAGER §2)
public enum Slug {
/// Lowercased, kebab-cased, alphanumerics + dashes only, collapsed and trimmed.
public static func sanitize(_ raw: String, fallback: String = "session") -> String {
var out = ""
var lastWasDash = false
for scalar in raw.lowercased().unicodeScalars {
if scalar == "-" || scalar == "_" || scalar == " " || scalar == "." || scalar == "/" {
if !lastWasDash && !out.isEmpty { out.append("-"); lastWasDash = true }
} else if scalar.properties.isAlphabetic || ("0"..."9").contains(scalar) {
out.unicodeScalars.append(scalar)
lastWasDash = false
}
// anything else is dropped
}
while out.hasSuffix("-") { out.removeLast() }
return out.isEmpty ? fallback : out
}
}