441 lines
20 KiB
Swift
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
|