1431 lines
84 KiB
Swift
1431 lines
84 KiB
Swift
import Foundation
|
||
|
||
/// User-configured provider resources that may be made available to an agent. The settings are
|
||
/// model-scoped rather than backend-scoped: two models served by the same CLI can deliberately have
|
||
/// different tool surfaces. Skills default to all models to preserve historical agent-home seeding;
|
||
/// external MCP servers and plugins remain opt-in because Claude has historically
|
||
/// launched with strict MCP configuration.
|
||
public enum ExternalAgentIntegration: String, CaseIterable, Sendable, Codable, Hashable {
|
||
case mcp
|
||
case skills
|
||
case plugins
|
||
|
||
public var defaultsKey: String { "nucleic.agents.external.\(rawValue).models" }
|
||
public var defaultModelSelection: String { self == .skills ? "*" : "" }
|
||
}
|
||
|
||
/// App-wide, model-scoped external-agent integration policy. Values are stored as either `*` (all
|
||
/// models) or a JSON array of model SKUs. Keeping the parser in Core makes launch decisions and the
|
||
/// Settings UI share one tolerant representation.
|
||
public enum ExternalAgentIntegrationSettings {
|
||
public static func models(from raw: String) -> Set<String> {
|
||
if raw.trimmingCharacters(in: .whitespacesAndNewlines) == "*" { return ["*"] }
|
||
guard let data = raw.data(using: .utf8),
|
||
let values = try? JSONDecoder().decode([String].self, from: data)
|
||
else { return [] }
|
||
return Set(values.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||
}
|
||
|
||
public static func encodedModels(_ models: Set<String>) -> String {
|
||
if models.contains("*") { return "*" }
|
||
return (try? String(data: JSONEncoder().encode(models.sorted()), encoding: .utf8)) ?? "[]"
|
||
}
|
||
|
||
public static func allows(
|
||
_ integration: ExternalAgentIntegration,
|
||
model: String?,
|
||
defaults: UserDefaults = .standard
|
||
) -> Bool {
|
||
guard let model, !model.isEmpty else { return false }
|
||
let raw = defaults.string(forKey: integration.defaultsKey)
|
||
?? integration.defaultModelSelection
|
||
let selected = models(from: raw)
|
||
return selected.contains("*") || selected.contains(model)
|
||
}
|
||
|
||
public static func enabledIntegrations(
|
||
for model: String?, defaults: UserDefaults = .standard
|
||
) -> Set<ExternalAgentIntegration> {
|
||
Set(ExternalAgentIntegration.allCases.filter { allows($0, model: model, defaults: defaults) })
|
||
}
|
||
}
|
||
|
||
// 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** the app pulls
|
||
/// + unpacks at runtime (`ContainerEngine`), cached by this exact ref.
|
||
///
|
||
/// **narOS** (docs/NAROS.md, milestone N2): the default is now `naros-agent` — the Nucleic
|
||
/// Agent Runtime OS agent tier, built by `.github/workflows/naros.yml` from `os/` (mmdebstrap
|
||
/// trixie base + `os/images/agent/Dockerfile`). It supersedes `nucleic-sandbox:v7`
|
||
/// (there is no v8): same control bridge, agent CLIs, and Playwright/Chromium — CI's
|
||
/// parity sweep (`os/tests/parity-sweep.sh`) guards the tool inventory — plus narOS identity
|
||
/// (`ID=naros`), **nash forced as `/bin/sh`/`/bin/bash`** (NASH.md §7.1/M3), `naros-init` as
|
||
/// PID 1, the `agent` user (uid `ContainerSpec.narosAgentUID`), baked Rust/Go/mise toolchains,
|
||
/// and warm shared caches. The tag tracks `os/VERSION`; bump the pin when cutting a release
|
||
/// (single-constant lockstep, asserted in CI — NAROS.md §8 — replacing the old four-place bump).
|
||
///
|
||
/// **Rollback levers**, strongest first: repoint this pin to ``legacyImage`` (image-level
|
||
/// rollback — the v7 image stays published); or set `ContainerServiceSettings.legacyShell`
|
||
/// (shell-level only: execs use the preserved real bash and nash is bypassed via
|
||
/// `NUCLEIC_NASH_DISABLE`, no image change). A per-project override (`ProjectSandbox.image`)
|
||
/// rolls back one project.
|
||
///
|
||
/// Two runtime escape hatches avoid waiting for an app release when only the CLIs need refreshing:
|
||
/// **Force re-creation** (Settings ▸ Control) re-pulls this exact ref from the registry — evicting
|
||
/// the local rootfs *and* image-store caches — so a tag re-pushed with a newer Codex is fetched
|
||
/// fresh; **Check for updates** `npm install -g …@latest` upgrades Codex/Claude Code in place
|
||
/// inside the running container with no re-pull. See `ContainerManager.updateAgentCLIs`.
|
||
public static let defaultImage = "ghcr.io/abkslm/naros-agent:26.07"
|
||
|
||
/// The last pre-narOS sandbox image (`containers/nucleic-sandbox/Dockerfile` history: `v2`
|
||
/// build tools; `v3` gh+curl; `v4` Codex/Grok CLIs + the control bridge; `v5` openssh-client;
|
||
/// `v6` @latest CLI pins + multi-arch; `v7` Playwright+Chromium). Kept published as the
|
||
/// image-level rollback for the narOS swap — repoint ``defaultImage`` here to roll back.
|
||
public static let legacyImage = "ghcr.io/abkslm/nucleic-sandbox:v7"
|
||
|
||
/// 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(_: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,
|
||
operation: nonisolated(nonsending) () async throws -> R
|
||
) async rethrows -> R {
|
||
try await $defaultsBox.withValue(
|
||
DefaultsBox(defaults: store), operation: operation)
|
||
}
|
||
|
||
/// 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"
|
||
/// Hard per-session memory ceiling (GiB) inside the SHARED control container. 0 (default) = off:
|
||
/// sessions burst freely and a runaway one is contained only by its own cgroup's oom.group (each
|
||
/// session already has its own cgroup — see the vendored containerization patch #9). Set > 0 to
|
||
/// also cap each session's `memory.max`, so one session can't consume the whole container's memory
|
||
/// before *its own* OOM. Only applies to the shared control container (per-session sandbox
|
||
/// containers already run one session each).
|
||
public static let controlPerSessionMemoryGiBKey = "nucleic.container.control.perSessionMemoryGiB"
|
||
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.
|
||
///
|
||
/// Always false on Linux: the Apple-containerization service exists only on macOS, and on a
|
||
/// runner the surrounding container IS the sandbox (docs/COVALENCE_RUNNER.md §4 tier 0) —
|
||
/// sessions run in place (`RunSpec.container == nil`) and tier-1 isolation arrives through
|
||
/// the RunnerPool acquire seam, not this switch. Without this gate, a controlled project
|
||
/// (the runner's default project shape) would pin the service on via
|
||
/// `reconcileContainerService` and every dispatch would die in the `ContainerManager` stub.
|
||
public static var serviceEnabled: Bool {
|
||
#if os(Linux)
|
||
return false
|
||
#else
|
||
return defaults.bool(forKey: serviceEnabledKey)
|
||
#endif
|
||
}
|
||
|
||
/// 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())
|
||
}
|
||
|
||
/// Hard per-session memory ceiling (GiB) in the shared control container; 0 = off. See the key doc.
|
||
public static var controlPerSessionMemoryGiB: Int {
|
||
resource(controlPerSessionMemoryGiBKey, default: 0)
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// The nash **rollback lever** (docs/NASH.md M3 / docs/NAROS.md N2). narOS images force nash as
|
||
/// the container's default shell (`/bin/sh`/`/bin/bash` diverted at image build); with this ON,
|
||
/// containerized execs run under the preserved real shell instead: `agentShellArgv` stops
|
||
/// preferring nash, and every hook env carries `NUCLEIC_NASH_DISABLE=1`, which makes any nash
|
||
/// still reached through the divert immediately re-exec the preserved `bash.real` (nash's M1
|
||
/// kill switch) — so shell-level regressions are isolatable from image regressions without
|
||
/// re-pulling an image. Default off. Takes effect for agent turns started after the change.
|
||
public static let legacyShellKey = "nucleic.container.legacyShell"
|
||
|
||
public static var legacyShell: Bool {
|
||
defaults.bool(forKey: legacyShellKey)
|
||
}
|
||
|
||
/// Route a Claude session's API traffic through Nucleic's in-process token-injecting proxy
|
||
/// (``ClaudeTokenProxy``) instead of handing the session a single static `CLAUDE_CODE_OAUTH_TOKEN`.
|
||
/// The proxy rewrites `Authorization` with a broker-fresh access token on every request, so a long
|
||
/// turn that outlives the ~1h token no longer 401s mid-turn — while the rotating refresh token
|
||
/// stays broker-only. **Opt-in (default off):** the host path is self-contained, but the
|
||
/// containerized path additionally relays a second unix socket + in-guest bridge that requires the
|
||
/// sandbox image/`naros-init` to launch it, so enable only on an image that carries the second
|
||
/// bridge. Takes effect for agent turns started after the change.
|
||
public static let claudeTokenProxyEnabledKey = "nucleic.claude.tokenProxy"
|
||
|
||
public static var claudeTokenProxyEnabled: Bool {
|
||
defaults.bool(forKey: claudeTokenProxyEnabledKey)
|
||
}
|
||
|
||
/// Whether the app keeps the sandbox **base image** up to date automatically. Two effects, both
|
||
/// gated on this and the container service being on: (1) a periodic registry check (a cheap
|
||
/// manifest-digest HEAD, `ContainerEngine.checkDefaultImageUpdate`) that surfaces a newer image in
|
||
/// Maintenance, and (2) auto-update on request — when a sandbox is next started and the registry
|
||
/// serves a newer digest than the cached rootfs, `ContainerEngine.ensureCachedRootfs` discards the
|
||
/// stale cache and pulls the new image instead of reusing the current one. Together they let a
|
||
/// refreshed image re-pushed to the pinned tag reach running sandboxes without an app release.
|
||
/// **Default on**; unset reads as `true`. An already-running container keeps its image until it's
|
||
/// re-created; a check/pull failure (offline, auth) leaves the cache in place.
|
||
public static let autoCheckImageUpdatesKey = "nucleic.container.autoCheckImageUpdates"
|
||
|
||
public static var autoCheckImageUpdates: Bool {
|
||
defaults.object(forKey: autoCheckImageUpdatesKey) == nil
|
||
? true
|
||
: defaults.bool(forKey: autoCheckImageUpdatesKey)
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// App-wide master switch for the `host_exec` tool — the agent's escape hatch to run commands on
|
||
/// the macOS host, OUTSIDE the sandbox container. ON by default whenever the container service is
|
||
/// on; turning it off hides the tool entirely — it is never advertised to the agent, and the
|
||
/// system prompt never mentions it exists — regardless of any per-project "Allow host build/run"
|
||
/// opt-in. A hard kill switch that sits above the per-project setting.
|
||
public static let hostExecEnabledKey = "nucleic.container.hostExecEnabled"
|
||
|
||
/// Whether sandboxed agents may be offered the `host_exec` tool at all (defaults to `true` when
|
||
/// unset; an explicit stored `false` still wins). Only meaningful while the container service is on.
|
||
public static var hostExecEnabled: Bool {
|
||
serviceEnabled && (defaults.object(forKey: hostExecEnabledKey) 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"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Settings for the **macOS host shell surface** (docs/NASH.md §7.7, §9.6): nash for the shells
|
||
/// *Nucleic itself spawns on the user's Mac* (`host_exec`, host monitors, Build/Run) — never a
|
||
/// divert, never the user's own shells. Deliberately its OWN namespace, not the
|
||
/// `nucleic.container.*` keys: a container rollback and a host rollback are different decisions.
|
||
///
|
||
/// Reads through ``ContainerServiceSettings/defaults`` for the same task-local test isolation.
|
||
public enum HostShellSettings {
|
||
private static var defaults: UserDefaults { ContainerServiceSettings.defaults }
|
||
|
||
/// `nash | zsh` — the shell for Nucleic's own non-interactive host spawns. **Default `zsh`
|
||
/// until M5.5's gates pass** (differential replay green + a clean `LoginShellEnv` dogfood
|
||
/// week, NASH.md §11): flipping is an explicit opt-in even after the code ships.
|
||
public static let shellKey = "nucleic.host.shell"
|
||
|
||
/// Whether host spawns should prefer the bundled nash: the shell setting says `nash` AND the
|
||
/// revert-everything lever is off. The binary's presence is a separate, per-call check
|
||
/// (`CommandInterceptor.hostShellArgv`) so a stripped bundle degrades silently to zsh.
|
||
public static var nashEnabled: Bool {
|
||
defaults.string(forKey: shellKey) == "nash" && !legacyShell
|
||
}
|
||
|
||
/// Host capture depth: `off | meta | tap`, default **`meta`** — the host filesystem is the
|
||
/// user's own machine, so data-flow previews (redirect/pipe/cmdsub) are opt-in here, inverting
|
||
/// the container default of `tap` (NASH.md §7.7.4).
|
||
public static let shellCaptureKey = "nucleic.host.shellCapture"
|
||
public static var shellCapture: String {
|
||
let raw = defaults.string(forKey: shellCaptureKey) ?? ""
|
||
return ["off", "meta", "tap"].contains(raw) ? raw : "meta"
|
||
}
|
||
|
||
/// The single revert-everything lever (NASH.md §9.6), mirroring the container's
|
||
/// `nucleic.container.legacyShell`: every host site back on `/bin/zsh -lc`, and host hook env
|
||
/// carries `NUCLEIC_NASH_DISABLE=1` so even a stray nash re-execs the fallback shell.
|
||
public static let legacyShellKey = "nucleic.host.legacyShell"
|
||
public static var legacyShell: Bool { defaults.bool(forKey: legacyShellKey) }
|
||
}
|
||
|
||
/// 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"
|
||
|
||
/// When on, the open chat's VM screen is shown in a small **Picture-in-Picture** window that floats
|
||
/// above every other window — even other apps — while that chat has a VM running, following the
|
||
/// conversation the same way ``autoOpenVMMonitorsKey`` follows the right column. Like every VM
|
||
/// interface surface it is look-only (the read-only monitor, no `VZVirtualMachineView`), so it never
|
||
/// captures the host's mouse or keyboard. ON by default. Only meaningful while a guest service
|
||
/// (macOS or Linux) is on.
|
||
public static let pictureInPictureVMMonitorsKey = "nucleic.macvm.pipVMMonitors"
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Whether the open chat's VM screen should float over everything as a Picture-in-Picture window
|
||
/// while that chat has a VM up (see ``pictureInPictureVMMonitorsKey``). Defaults to `true` when
|
||
/// unset; an explicit stored `false` still wins. Only meaningful while a guest service is on.
|
||
public static var pictureInPictureVMMonitors: Bool {
|
||
(serviceEnabled || linuxServiceEnabled)
|
||
&& (defaults.object(forKey: pictureInPictureVMMonitorsKey) as? Bool ?? true)
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Apps/packages recorded from a base that's being **deleted** so the *next* base build can carry
|
||
/// them forward into the replacement — the durable stash behind "an OS-version update (delete +
|
||
/// reinstall) doesn't lose the apps the user had installed" (docs/MACOS_VM.md §9.1). The engine
|
||
/// writes these in `deleteBaseImage` from the outgoing base's `bundle.json`, unions them with the
|
||
/// live Settings selection when provisioning the new base, and clears them once that succeeds.
|
||
/// (A no-delete "Rebuild" carries the apps straight from the surviving base's own `bundle.json`, so
|
||
/// these keys stay empty on that path.)
|
||
public static let pendingBaseCarryAppsKey = "nucleic.macvm.pendingBaseCarryApps"
|
||
public static let pendingBaseCarryPackagesKey = "nucleic.macvm.pendingBaseCarryPackages"
|
||
|
||
public static var pendingBaseCarryApps: [String] {
|
||
defaults.stringArray(forKey: pendingBaseCarryAppsKey) ?? []
|
||
}
|
||
public static var pendingBaseCarryPackages: [String] {
|
||
defaults.stringArray(forKey: pendingBaseCarryPackagesKey) ?? []
|
||
}
|
||
|
||
/// Record what a base being deleted had installed, for the next build to restore. An empty/empty
|
||
/// pair clears the stash (nothing to carry).
|
||
public static func setPendingBaseCarry(apps: [String], packages: [String]) {
|
||
if apps.isEmpty { defaults.removeObject(forKey: pendingBaseCarryAppsKey) }
|
||
else { defaults.set(apps, forKey: pendingBaseCarryAppsKey) }
|
||
if packages.isEmpty { defaults.removeObject(forKey: pendingBaseCarryPackagesKey) }
|
||
else { defaults.set(packages, forKey: pendingBaseCarryPackagesKey) }
|
||
}
|
||
|
||
/// Clear the carry stash once the replacement base has been provisioned with it (the new base's
|
||
/// own `bundle.json` now records the set, so the stash has done its job).
|
||
public static func clearPendingBaseCarry() {
|
||
defaults.removeObject(forKey: pendingBaseCarryAppsKey)
|
||
defaults.removeObject(forKey: pendingBaseCarryPackagesKey)
|
||
}
|
||
|
||
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"
|
||
|
||
/// The **kernel + initrd stay the externally-sourced Ubuntu arm64 artifacts** (26.04 LTS
|
||
/// "resolute"). Changing the kernel/initrd flow is a narOS non-goal (NAROS.md §non-goals: "the VM
|
||
/// continues to use the externally-sourced kernel/initrd flow… narOS owns the *rootfs*") — so N4
|
||
/// swaps only the rootfs, below. The rootfs's kernel *modules* still match this Ubuntu kernel
|
||
/// (``defaultLinuxModulesManifestURL``), independent of the rootfs userland.
|
||
///
|
||
/// 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"
|
||
/// Plain-URL rootfs override (escape hatch). **Empty by default** — the default rootfs is the
|
||
/// narOS OCI artifact below, not a URL. When set, ``resolveLinuxArtifacts`` downloads this instead.
|
||
public static let defaultLinuxRootfsURL = ""
|
||
|
||
/// The **narOS VM rootfs** the two-phase build boots, published by `naros.yml` as a single-blob OCI
|
||
/// artifact (same channel as the guest agents / the pre-unwrapped kernel). Default: the **desktop
|
||
/// flavor** (`naros-vm-desktop`, N5) — GNOME 50 / Mutter baked from a pinned Debian forky snapshot,
|
||
/// for computer-use + the AT-SPI semantic agent (it replaces the retired Ubuntu guest). The headless
|
||
/// flavor (`naros-vm`, N4 — exec-only, trixie) is available by overriding the key. Override the ref
|
||
/// via the key; empty → the pinned default. Bump the tag with `os/VERSION`.
|
||
public static let linuxRootfsImageKey = "nucleic.linuxvm.rootfsImage"
|
||
public static let defaultLinuxRootfsImage = "ghcr.io/abkslm/naros-vm-desktop:26.07"
|
||
|
||
/// The cloud-image manifest pinning the **external kernel's** `linux-modules-<ver>-generic` version
|
||
/// (`scripts/fetch-kernel-modules.sh`). Tied to the Ubuntu kernel/initrd above — the modules must
|
||
/// match the kernel we boot — and therefore kept **independent of the (narOS) rootfs source**.
|
||
public static let linuxModulesManifestURLKey = "nucleic.linuxvm.modulesManifestURL"
|
||
public static let defaultLinuxModulesManifestURL =
|
||
"https://cloud-images.ubuntu.com/releases/resolute/release/"
|
||
+ "ubuntu-26.04-server-cloudimg-arm64.manifest"
|
||
|
||
/// The OS version the Linux VM base reports (stamped into `bundle.json`, shown in Settings). Bump
|
||
/// with `os/VERSION`.
|
||
public static let defaultLinuxOSVersion = "26.07"
|
||
|
||
// The **Linux guest agents** (`nucleic-linux-agent`, the static-C vsock control agent; and
|
||
// `nucleic-a11y-agent`, the Rust AT-SPI semantic agent) have no settings here on purpose. They are
|
||
// versioned with the rootfs and baked into it from the narOS apt pool
|
||
// (os/mkimage/profiles/vm*.naros-pkgs) — the same debs published to the hosted repo. The host once
|
||
// also pulled them from `ghcr.io/abkslm/nucleic-{linux,a11y}-agent:latest` and had the phase-1
|
||
// bootstrap overlay them onto the unpacked rootfs, so a VM could ship one build of the agent and
|
||
// run another (`:latest` moved independently of the rootfs tag). Pinning `linuxRootfsImage` now
|
||
// pins the agents too.
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Debug switch for **Linux base image builds** (docs/LINUX_VM.md §Debugging a failed base build).
|
||
/// When on: the provision boot drops `quiet` for a verbose console (journald forwarded to the
|
||
/// serial log), and the payload bakes `/etc/nucleic-debug` into the rootfs so the in-guest
|
||
/// `nucleic-bootdebug` unit emits its full snapshot even on healthy boots. A failed provision
|
||
/// always writes `debug-report.txt` into the bundle, debug switch or not.
|
||
public static let linuxBaseBuildDebugKey = "nucleic.linuxvm.baseBuildDebug"
|
||
public static var linuxBaseBuildDebug: Bool { defaults.bool(forKey: linuxBaseBuildDebugKey) }
|
||
|
||
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) }
|
||
public static var linuxRootfsImage: String { linuxURL(linuxRootfsImageKey, default: defaultLinuxRootfsImage) }
|
||
public static var linuxModulesManifestURL: String {
|
||
linuxURL(linuxModulesManifestURLKey, default: defaultLinuxModulesManifestURL)
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
/// Covalence runner settings (docs/COVALENCE_RUNNER.md §7): the **max runners** cap the user sets
|
||
/// in Settings ▸ Covalence ▸ Covalence Cloud. It configures the runner *pool* (the Mac pushes it
|
||
/// to the control plane via `PATCH /v1/pool/settings`, which clamps it server-side too); it
|
||
/// mirrors the macOS VM cap pattern (`MacVMSettings` maxConcurrent + `MacVMManager.ensureRunning`
|
||
/// enforcement) onto the cloud pool — the ceiling on how many independent runner peers the pool
|
||
/// scales up to on demand.
|
||
public enum RunnerSettings {
|
||
private static var defaults: UserDefaults { ContainerServiceSettings.defaults }
|
||
|
||
/// The most concurrent runner peers the pool may run. Each runner is a full `nucleicd` host
|
||
/// with its own mesh identity; the fleet scales up on demand (a parked mesh dispatch boots
|
||
/// one more, up to this cap) and reaps to zero. Clamped to ``maxRunnersRange``; the pool
|
||
/// re-clamps on `PATCH /settings` so a stale client can't exceed it. Mirrors
|
||
/// `nucleic.macvm.maxConcurrent`.
|
||
public static let maxRunnersKey = "nucleic.runner.maxRunners"
|
||
/// Whether the runner is enabled at all (the "Enable runner" switch). Off by default; a
|
||
/// runner only provisions when the user turns it on, like every other service switch.
|
||
public static let serviceEnabledKey = "nucleic.runner.serviceEnabled"
|
||
/// The runner control-plane base URL (dev/self-host override). Empty ⇒ the production
|
||
/// `runner.nucleic.blakeslee.xyz`.
|
||
public static let controlURLKey = "nucleic.runner.controlURL"
|
||
|
||
public static let defaultMaxRunners = 4
|
||
/// Same 1…16 bounds the Worker enforces (`MAX_RUNNERS_RANGE` in pool.ts), so the stepper
|
||
/// and the server agree.
|
||
public static let maxRunnersRange = 1...16
|
||
|
||
/// Whether the runner service is enabled app-wide.
|
||
public static var serviceEnabled: Bool { defaults.bool(forKey: serviceEnabledKey) }
|
||
|
||
/// The configured max-runners cap, clamped to ``maxRunnersRange``. Unset ⇒
|
||
/// ``defaultMaxRunners``.
|
||
public static var maxRunners: Int {
|
||
let stored = defaults.object(forKey: maxRunnersKey) as? Int ?? defaultMaxRunners
|
||
return min(maxRunnersRange.upperBound, max(maxRunnersRange.lowerBound, stored))
|
||
}
|
||
|
||
/// Configured control-plane URL, or `nil`/empty when unset (⇒ production).
|
||
public static var controlURL: String? {
|
||
let v = (defaults.string(forKey: controlURLKey) ?? "")
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return v.isEmpty ? nil : v
|
||
}
|
||
|
||
/// How a runner replaces Apple Foundation Models (docs/COVALENCE_RUNNER.md §5, item 6).
|
||
/// Persisted under ``intelligenceModeKey``; `NUCLEIC_RUNNER_INTELLIGENCE_MODE` overrides it
|
||
/// per boot (the container path, where env is the config surface).
|
||
public static let intelligenceModeKey = "nucleic.runner.intelligenceMode"
|
||
|
||
/// The configured intelligence mode. Unset ⇒ ``RunnerIntelligenceMode/mesh`` — free (no
|
||
/// agent tokens), private (E2EE to the user's own devices), and it degrades to heuristics
|
||
/// by itself when no capable device is connected.
|
||
public static var intelligenceMode: RunnerIntelligenceMode {
|
||
RunnerIntelligenceMode(rawValue: defaults.string(forKey: intelligenceModeKey) ?? "")
|
||
?? .mesh
|
||
}
|
||
}
|
||
|
||
/// The runner's `IntelligenceProviding` choice (COVALENCE_RUNNER §5): `agent` runs a small
|
||
/// SKU of the user's authenticated agent behind strict templates, `mesh` delegates to
|
||
/// Apple-Intelligence-capable devices on the mesh, `heuristic` skips models entirely. Modes
|
||
/// 1–2 always terminate in the heuristic fallback when they can't serve. Mirrors the
|
||
/// `IntelligenceMode` union in `cloud/nucleic-runner/src/pool.ts` (keep in lockstep).
|
||
public enum RunnerIntelligenceMode: String, CaseIterable, Sendable {
|
||
case agent
|
||
case mesh
|
||
case heuristic
|
||
|
||
public var displayName: String {
|
||
switch self {
|
||
case .agent: return "Agent (small model)"
|
||
case .mesh: return "Your devices (Apple Intelligence)"
|
||
case .heuristic: return "Off (heuristics only)"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|