Files
nucleic/Sources/NucleicCore/LinuxSupport.swift
T

398 lines
17 KiB
Swift

import Foundation
// Linux substitutes for the Apple-only surface NucleicCore leans on (docs/CARBON_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.
// MARK: - os.Logger shim
#if !canImport(os)
/// The narrow slice of `os.Logger` NucleicCore uses (module-level loggers + privacy-annotated
/// interpolation), emitted to stderr with the subsystem/category label. Structured logging can
/// replace this with swift-log later; the call sites won't change.
public struct Logger: Sendable {
public struct Message: ExpressibleByStringInterpolation, Sendable {
public struct Privacy: Sendable {
public static let `public` = Privacy()
public static let `private` = Privacy()
public static let auto = Privacy()
public static let sensitive = Privacy()
}
public struct StringInterpolation: StringInterpolationProtocol, Sendable {
var rendered = ""
public init(literalCapacity: Int, interpolationCount: Int) {
rendered.reserveCapacity(literalCapacity + interpolationCount * 8)
}
public mutating func appendLiteral(_ literal: String) { rendered += literal }
public mutating func appendInterpolation<T>(_ value: @autoclosure () -> T) {
rendered += String(describing: value())
}
public mutating func appendInterpolation<T>(
_ value: @autoclosure () -> T, privacy: Privacy
) {
rendered += String(describing: value())
}
}
let rendered: String
public init(stringLiteral value: String) { rendered = value }
public init(stringInterpolation: StringInterpolation) {
rendered = stringInterpolation.rendered
}
}
let label: String
public init(subsystem: String, category: String) { label = "\(subsystem).\(category)" }
public func trace(_ message: Message) { emit("trace", message) }
public func debug(_ message: Message) { emit("debug", message) }
public func info(_ message: Message) { emit("info", message) }
public func notice(_ message: Message) { emit("notice", message) }
public func warning(_ message: Message) { emit("warning", message) }
public func error(_ message: Message) { emit("error", message) }
public func fault(_ message: Message) { emit("fault", message) }
private func emit(_ level: String, _ message: Message) {
FileHandle.standardError.write(Data("[\(label)] \(level): \(message.rendered)\n".utf8))
}
}
#endif
// 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)
}
let home = FileManager.default.homeDirectoryForCurrentUser
return home.appendingPathComponent(".config/nucleic/secrets", isDirectory: true)
}
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 FileManager.default.createDirectory(
at: dir, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
let target = url(for: account)
try data.write(to: target, options: .atomic)
try FileManager.default.setAttributes(
[.posixPermissions: 0o600], ofItemAtPath: target.path)
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 oomKills: Int?
public let sample: ContainerResourceSample?
public init(containerRunning: Bool, oomKills: Int?, sample: ContainerResourceSample?) {
self.containerRunning = containerRunning
self.oomKills = oomKills
self.sample = sample
}
}
/// 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/CARBON_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.
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 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 controlContainerUsage() async -> ContainerResourceSample? { nil }
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 reclaimMemoryNow() 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 init(
name: String, ipAddress: String?, os: GuestOS = .macOS,
paused: Bool = false, resuming: Bool = false, suspendedToDisk: Bool = false
) {
self.name = name
self.ipAddress = ipAddress
self.os = os
self.paused = paused
self.resuming = resuming
self.suspendedToDisk = suspendedToDisk
}
}
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] = [:]
) async throws -> (exitCode: Int32, stdout: String, stderr: String) {
throw LinuxUnsupported()
}
public func performComputerAction(
name: String, action: String, x: Int?, y: Int?, text: String?,
ref: String? = nil, value: 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 suspendIfIdle(_ session: SessionID) async {}
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 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 isWorkingInBackground(name: String) -> Bool { false }
public func backgroundLog(name: String) async -> [MacVMLogLine] { [] }
public func runningVMs() async -> [MacVMEntry] { [] }
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 baseImageOSVersion() async -> String? { nil }
public func baseStatus() async -> MacVMBaseStatus { MacVMBaseStatus() }
public func linuxBaseStatus() async -> MacVMBaseStatus { MacVMBaseStatus() }
public func baseBundleRootPath() async -> String? { nil }
public func beginBaseRecovery() async throws -> String { throw LinuxUnsupported() }
public func endBaseRecovery() async {}
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) async throws {
throw LinuxUnsupported()
}
public func buildLinuxBaseImage() async throws { throw LinuxUnsupported() }
public func deleteLinuxBaseImage() 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