Files
nucleic/Sources/NucleicCore/LinuxSupport.swift
T

441 lines
20 KiB
Swift

import Foundation
// Linux substitutes for the Apple-only surface NucleicCore leans on (docs/COVALENCE_RUNNER.md
// §9, CLOUD_RUNTIME §3.1). Everything here is `#if`-gated to compile ONLY where the Apple
// original is absent, so Darwin builds are untouched. The Windows build (docs/WINDOWS_PORT.md)
// shares these stand-ins too, with one exception: Windows compiles the REAL
// `Container/ContainerManager.swift` (retargeted onto `SandboxEngine`), so the stub
// `ContainerManager` below is additionally gated `!os(Windows)`.
// The `os.Logger` shim lives in PortableLogging.swift (shared with the Windows build —
// docs/WINDOWS_PORT.md §4.3).
// MARK: - File-based secret store (the Keychain's Linux stand-in)
#if !canImport(Security)
/// Where the Keychain-backed stores keep their secrets on Linux: 0600 files under a 0700 dir,
/// one per account string — the CLOUD_RUNTIME §3.1 "0600-file secret store" seam. Inside a
/// runner container this directory lives on the container's own disk (checkpointed per
/// CLOUD_RUNTIME §3.5), never in the repo or a mount the agent works in.
enum LinuxSecretStore {
static var baseDirectory: URL {
if let override = ProcessInfo.processInfo.environment["NUCLEIC_SECRETS_DIR"],
!override.isEmpty
{
return URL(fileURLWithPath: override, isDirectory: true)
}
#if os(Windows)
// Windows has no XDG convention and no dot-dir habit: secrets belong in the same
// per-channel data root as the store (docs/WINDOWS_PORT.md §4.2/§4.4), owner-only via
// the DACL that `SecretFile` applies below.
return NucleicPaths.secretsDirectory
#else
let home = FileManager.default.homeDirectoryForCurrentUser
return home.appendingPathComponent(".config/nucleic/secrets", isDirectory: true)
#endif
}
private static func url(for account: String) -> URL {
// Account strings are reverse-DNS-ish identifiers; keep the mapping readable while
// guarding against separators.
let safe = account.replacingOccurrences(of: "/", with: "_")
return baseDirectory.appendingPathComponent(safe)
}
static func read(account: String) -> Data? {
try? Data(contentsOf: url(for: account))
}
@discardableResult
static func write(account: String, data: Data) -> Bool {
let dir = baseDirectory
do {
try SecretFile.createDirectory(at: dir)
try SecretFile.write(data, to: url(for: account))
return true
} catch {
return false
}
}
static func remove(account: String) {
try? FileManager.default.removeItem(at: url(for: account))
}
}
#endif
// MARK: - Sandbox-service stubs
#if !canImport(Containerization)
/// The two container-probe value types the engine defines on Darwin
/// (`Container/ContainerEngine.swift`); mirrored here so the shared surface compiles.
public struct ContainerResourceSample: Sendable, Equatable {
public let cpuPercent: Double
public let memoryUsedBytes: UInt64
public let memoryTotalBytes: UInt64
public init(cpuPercent: Double, memoryUsedBytes: UInt64, memoryTotalBytes: UInt64) {
self.cpuPercent = cpuPercent
self.memoryUsedBytes = memoryUsedBytes
self.memoryTotalBytes = memoryTotalBytes
}
/// Memory utilization 0…100. Zero when the total is unknown (avoids a divide-by-zero).
public var memoryPercent: Double {
memoryTotalBytes == 0 ? 0 : min(100, Double(memoryUsedBytes) / Double(memoryTotalBytes) * 100)
}
}
public struct ContainerKillDiagnosis: Sendable, Equatable {
public let containerRunning: Bool
public let probeUnanswered: Bool
public let oomKills: Int?
public let sample: ContainerResourceSample?
public init(
containerRunning: Bool, probeUnanswered: Bool = false, oomKills: Int?,
sample: ContainerResourceSample?
) {
self.containerRunning = containerRunning
self.probeUnanswered = probeUnanswered
self.oomKills = oomKills
self.sample = sample
}
}
/// Namespace mirror of the Darwin engine, carrying the one nested type the shared backend
/// surface names (`ControlPlaneProbe`, returned by `probeControlPlane`). Keep in lockstep with
/// `Container/ContainerEngine.swift`. Windows uses this mirror too: the real engine file is
/// excluded there and the wslc engine is its own type (docs/WINDOWS_PORT.md §3.2).
public enum ContainerEngine {
public enum ControlPlaneProbe: Sendable, Equatable {
case responsive
case bridgeUnreachable(String)
case unresponsive
case probeFailed(String)
}
}
/// Mirror of the Darwin engine's error surface (same file), shared by the Windows wslc
/// engine so call sites keep one error taxonomy cross-platform. Keep in lockstep.
public enum ContainerError: Error, Sendable, CustomStringConvertible {
case unavailable(String)
case notRunning(String)
case imagePullFailed(String)
case rootfsBuildFailed(String)
case startFailed(String)
case gatewayUnavailable
public var description: String {
switch self {
case let .unavailable(why):
return "Container runtime unavailable: \(why)"
case let .notRunning(name):
return "Container \"\(name)\" isn't running."
case let .imagePullFailed(msg):
return "Failed to pull the sandbox base image: \(msg)"
case let .rootfsBuildFailed(msg):
return "Failed to build the sandbox root filesystem: \(msg)"
case let .startFailed(msg):
return "Failed to start the container VM: \(msg)"
case .gatewayUnavailable:
return "Couldn't determine the host gateway address for the container."
}
}
}
#endif
#if !canImport(Containerization) && !os(Windows)
/// Linux stand-in for the Apple-containerization sandbox orchestrator. On a runner the
/// container IS the sandbox (`RunSpec.container` stays nil — CLOUD_RUNTIME §0), so the
/// service is never enabled and the throwing members are never reached; they exist so the
/// shared object graph (AppStore, backends, SessionController signatures) compiles unchanged.
/// Tier-1 session sandboxes on Linux arrive later via the RunnerPool acquire seam
/// (docs/COVALENCE_RUNNER.md §4), not through this type. The pure naming statics mirror the
/// Darwin originals exactly (same channel defines), so names stay identical cross-platform.
/// Not compiled on Windows, which builds the real `Container/ContainerManager.swift` against
/// the `SandboxEngine` protocol instead (docs/WINDOWS_PORT.md §3.1).
public actor ContainerManager {
public struct LinuxUnsupported: Error, CustomStringConvertible {
public let description = "the Apple-containerization sandbox service does not exist on Linux"
}
// Naming statics — byte-identical to Container/ContainerManager.swift; keep in lockstep.
public nonisolated static let channelSuffix: String = {
#if NUCLEIC_STABLE
return ""
#elseif NUCLEIC_RC
return "-rc"
#elseif NUCLEIC_BETA
return "-beta"
#elseif NUCLEIC_CANARY
return "-canary"
#else
return "-local"
#endif
}()
public nonisolated static let nonReleaseSuffixes = ["-local", "-canary", "-beta", "-rc"]
public nonisolated static func ownsContainer(named name: String) -> Bool {
channelSuffix.isEmpty
? !nonReleaseSuffixes.contains(where: name.hasSuffix)
: name.hasSuffix(channelSuffix)
}
public nonisolated static func containerName(for session: SessionID) -> String {
"nucleic-\(session.short.lowercased())\(channelSuffix)"
}
public static let sharedControlContainerName = "nucleic-control" + channelSuffix
public static let claudeControlContainerName = "nucleic-control-claude" + channelSuffix
public static let codexControlContainerName = "nucleic-control-codex" + channelSuffix
public static let xaiControlContainerName = "nucleic-control-xai" + channelSuffix
public static let opencodeControlContainerName = "nucleic-control-opencode" + channelSuffix
public static let openclawControlContainerName = "nucleic-control-openclaw" + channelSuffix
public static let hermesControlContainerName = "nucleic-control-hermes" + channelSuffix
public static let cursorControlContainerName = "nucleic-control-cursor" + channelSuffix
public static let allSharedControlContainerNames = [
sharedControlContainerName, claudeControlContainerName, codexControlContainerName,
xaiControlContainerName, opencodeControlContainerName, openclawControlContainerName,
hermesControlContainerName, cursorControlContainerName,
]
public nonisolated static func sharedContainerName(for backend: BackendID, split: Bool) -> String {
guard split else { return sharedControlContainerName }
switch backend {
case .claudeCode: return claudeControlContainerName
case .codex, .codexExec: return codexControlContainerName
case .grok: return xaiControlContainerName
case .opencode: return opencodeControlContainerName
case .openclaw: return openclawControlContainerName
case .hermes: return hermesControlContainerName
case .cursorAgent: return cursorControlContainerName
case .acp: return sharedControlContainerName
}
}
let onSharedContainerRemoved: @Sendable (String) async -> Void
public init(onSharedContainerRemoved: @Sendable @escaping (String) async -> Void = { _ in }) {
self.onSharedContainerRemoved = onSharedContainerRemoved
}
public func ensureRunning(_ spec: ContainerSpec) async throws
-> (name: String, hostGateway: String)
{
throw LinuxUnsupported()
}
public func exec(
name: String, workdir: String, env: [String: String], argv: [String],
uid: Int? = nil, gid: Int? = nil
) async throws -> any ProcessHandle {
throw LinuxUnsupported()
}
public func finished(name: String) {}
public func holdForBackgroundWait(_ session: SessionID) {}
public func releaseBackgroundWait(_ session: SessionID) {}
public func isRunning(name: String) async -> Bool { false }
public func stopAgentContainer(name: String) async {}
public func removeAgentContainer(name: String) async -> Bool { false }
public func teardown(_ session: SessionID, waitForActive: Bool = false) async -> String? { nil }
public func teardownShared() async {}
public func controlContainerStatus() async -> (exists: Bool, running: Bool) { (false, false) }
public func controlContainers() async -> [ControlContainerEntry] { [] }
public func controlContainerUsages() async -> [String: ContainerResourceSample] { [:] }
public func controlDownloadProgress() async -> ContainerDownloadProgress? { nil }
public func diagnoseContainerKill(name: String) async -> ContainerKillDiagnosis {
ContainerKillDiagnosis(containerRunning: false, oomKills: nil, sample: nil)
}
public func reconcile(activeSessions: [SessionID]) async {}
public func recreateShared() async {}
public func restartShared() async {}
public func updateAgentCLIs() async -> [AgentCLIUpdateResult] { [] }
public func checkImageUpdate() async -> ImageUpdateStatus { .idle }
public func prefetchImage() async {}
public func reclaimMemoryNow() async {}
public func sessionHostGateway() async throws -> String? { nil }
public func probeControlPlane(name: String) async -> ContainerEngine.ControlPlaneProbe {
.probeFailed("the Apple-containerization sandbox service does not exist on Linux")
}
public func requestControlPlaneRecovery(name: String) async {}
public func requestNetworkRecovery(name: String, hostname: String) async {}
}
#endif
#if !canImport(Virtualization)
/// `UncheckedSendableBox` lives in `MacVM/MacVMEngine+Base.swift` on Darwin (excluded here);
/// `MacVMSurfaceHost` (portable, kept) names it in a signature, so mirror the box.
public struct UncheckedSendableBox<T>: @unchecked Sendable {
public let value: T
public init(_ value: T) { self.value = value }
}
/// Mirrors of the two Control-panel value types `MacVM/MacVMManager.swift` defines on Darwin.
public struct MacVMEntry: Sendable, Equatable {
public let name: String
public let ipAddress: String?
public let os: GuestOS
public let paused: Bool
public let resuming: Bool
public let suspendedToDisk: Bool
public let booting: Bool
public init(
name: String, ipAddress: String?, os: GuestOS = .macOS,
paused: Bool = false, resuming: Bool = false, suspendedToDisk: Bool = false,
booting: Bool = false
) {
self.name = name
self.ipAddress = ipAddress
self.os = os
self.paused = paused
self.resuming = resuming
self.suspendedToDisk = suspendedToDisk
self.booting = booting
}
}
public struct MacVMMaintenanceInfo: Sendable, Equatable {
public let name: String
public let os: GuestOS
public let activity: String
public let booting: Bool
public init(name: String, os: GuestOS, activity: String, booting: Bool) {
self.name = name
self.os = os
self.activity = activity
self.booting = booting
}
}
/// Linux stand-in for the macOS/Linux-VM orchestrator (`mac_vm_*` / `linux_vm_*` tools). The VM
/// service is Darwin-only (Apple Virtualization); on a runner these tools are simply never
/// exposed (`isSupported` is false, `MacVMSettings.serviceEnabled` defaults off, and no
/// `RunSpec.allow*VM*` is ever set), so the throwing members are never reached. Exists so the
/// shared object graph compiles unchanged. Signatures mirror `MacVM/MacVMManager.swift`.
public actor MacVMManager {
public struct LinuxUnsupported: Error, CustomStringConvertible {
public let description = "the macOS/Linux VM service does not exist on a Linux runner"
}
public nonisolated static var isSupported: Bool { false }
public nonisolated static func vmName(for session: SessionID) -> String {
"nucleic-mac-\(session.short.lowercased())\(ContainerManager.channelSuffix)"
}
public nonisolated static func linuxVMName(for session: SessionID) -> String {
"nucleic-lin-\(session.short.lowercased())\(ContainerManager.channelSuffix)"
}
public init() {}
public func ensureRunning(_ spec: MacVMSpec) async throws -> (name: String, ipAddress: String) {
throw LinuxUnsupported()
}
public func exec(
name: String, workdir: String?, env: [String: String], argv: [String]
) async throws -> any ProcessHandle {
throw LinuxUnsupported()
}
public func run(
name: String, command: String, workdir: String?, env: [String: String] = [:],
timeoutNanos: UInt64? = nil, asBackgroundWork: Bool = true
) async throws -> (exitCode: Int32, stdout: String, stderr: String) {
throw LinuxUnsupported()
}
/// Mirrors ``MacVMManager/revalidateBaseMDMPolicy()``. Returns `Void` rather than the Darwin
/// `MacVMEngine.MDMOutcome` (the engine type doesn't exist here); the shared call site
/// (`AppStore.revalidateMacVMBaseMDMPolicy`) discards the result, and no VM service exists to
/// reach this anyway.
public func revalidateBaseMDMPolicy() async throws {
throw LinuxUnsupported()
}
/// Mirrors ``MacVMEngine/drainShellEvents(name:)``: best-effort and non-throwing on Darwin, so the
/// stub returns an empty drain rather than throwing (no VM ever runs here to spool events).
public func drainShellEvents(name: String) async -> [JSONValue] { [] }
public func performComputerAction(
name: String, action: String, x: Int?, y: Int?, text: String?,
ref: String? = nil, value: String? = nil, url: String? = nil,
scrollDirection: String?, scrollAmount: Int?, durationMs: Int?
) async throws -> (imageBase64: String?, summary: String) {
throw LinuxUnsupported()
}
public func installApps(name: String, appPaths: [String]) async throws -> [String] {
throw LinuxUnsupported()
}
public func installAppsIntoBase(appPaths: [String]) async throws -> [String] {
throw LinuxUnsupported()
}
public func installPackages(name: String, packageIDs: [String]) async throws -> [String] {
throw LinuxUnsupported()
}
public func installPackagesIntoBase(packageIDs: [String]) async throws -> [String] {
throw LinuxUnsupported()
}
public func finished(name: String) async {}
public func holdForBackgroundWait(_ session: SessionID) {}
public func releaseBackgroundWait(_ session: SessionID) {}
public func suspendIfIdle(_ session: SessionID) async {}
public func scheduleDoneReap(
_ session: SessionID, stillDone: @escaping @Sendable () async -> Bool
) {}
public func teardown(_ session: SessionID, waitForActive: Bool = false) async -> String? { nil }
public func restart(_ session: SessionID) async {}
public func stop(name: String) async {}
public func suspend(name: String) async -> Bool { false }
public func resume(name: String) async -> Bool { false }
public func takeRestoreFailure(name: String) async -> String? { nil }
public func restart(name: String) async {}
public func remove(name: String) async -> Bool { false }
public func exists(name: String) async -> Bool { false }
public func isPaused(name: String) async -> Bool { false }
public func isRunning(name: String) async -> Bool { false }
public func isInUse(name: String) -> Bool { false }
public func isWaiting(name: String) -> Bool { false }
public func isWorkingInBackground(name: String) -> Bool { false }
public func backgroundLog(name: String) async -> [MacVMLogLine] { [] }
public func runningVMs() async -> [MacVMEntry] { [] }
/// Mirrors the macOS manager's registry change stream (plan item 7): no VM service here, so
/// yield one empty snapshot and finish — consumers fall back to their backstop poll.
public func vmChanges() async -> AsyncStream<[MacVMEntry]> {
AsyncStream { continuation in
continuation.yield([])
continuation.finish()
}
}
public func baseMaintenanceVM() async -> MacVMMaintenanceInfo? { nil }
public func captureScreen(name: String) async -> String? { nil }
public func captureScreenData(name: String) async -> Data? { nil }
public func sampleResourceUsage(name: String) async -> MacVMResourceSample? { nil }
public func baseProgress() async -> MacVMBaseProgress? { nil }
public func baseBuildGuest() async -> GuestOS? { nil }
public func baseImageOSVersion() async -> String? { nil }
public func baseStatus() async -> MacVMBaseStatus { MacVMBaseStatus() }
public func linuxBaseStatus() async -> MacVMBaseStatus { MacVMBaseStatus() }
public func setSurfaceHost(_ host: any MacVMSurfaceHost) async {}
public func setEvictionPolicy(_ ranker: @escaping @Sendable ([String]) async -> [String]) async {}
public func buildBaseImage(localRestoreImagePath: String? = nil) async throws {
throw LinuxUnsupported()
}
public func deleteBaseImage(includingRestoreImages: Bool, force: Bool = false) async throws {
throw LinuxUnsupported()
}
public func forceCancelBaseWork() async -> String { "No VM service in this build." }
public func buildLinuxBaseImage() async throws { throw LinuxUnsupported() }
public func deleteLinuxBaseImage(force: Bool = false) async throws { throw LinuxUnsupported() }
public func setBaseProvisionObserver(visible: Bool) async {}
public func presentOperatorAssist(name: String, visible: Bool) async {}
public func reconcile(activeSessions: [SessionID]) async {}
public func reprovisionStaleBaseImagesIfNeeded() async {}
}
#endif